{
  "openapi": "3.1.0",
  "info": {
    "title": "Commune API",
    "version": "2026-08-26",
    "summary": "Newsletters, articles, threads and the people around them.",
    "description": "The Commune API exposes a Commune community: newsletters, the articles they\npublish, the chat threads those articles start, and the people who write\nand read them.\n\nMost of it is reading. A small set of operations changes something, and\nthose behave differently in three ways described under Writes below.\n\n## Fetching this document\n\nThis contract is served by the API itself, in two syntaxes carrying the same\ncontent: `https://api.usecommune.com/openapi.yaml` and\n`https://api.usecommune.com/openapi.json`. Generate a client from whichever\nyour toolchain prefers.\n\n`?version=` selects a contract version, the same way the `Commune-Version`\nheader does for a request, and answers `404` for a version that was never\nreleased. `?profile=docs` returns the variant the published reference is\nrendered from; it differs only in presentation metadata, so the operations,\nwebhooks and schemas are identical either way.\n\n## Versioning\n\nThe base URL carries no version segment. A request selects a contract\nversion with the `Commune-Version` header, whose value is the release date\nof the contract (for example `2026-08-26`). Omitting the header pins the\nrequest to the version that was current when the API key was issued.\n\nEvery response echoes the version it resolved to in a `Commune-Version`\nresponse header, on success and on failure alike. A client that never sets\nthe header can read which contract it has been getting, and compare it\nagainst `version` in `GET /status` to find out whether a newer one is\navailable to move to.\n\nEvery response also carries a `Commune-Request-Id`, which is the value that\nappears as `request_id` in an error body. Quote it in support requests.\n\n## Authentication\n\nEvery request is authenticated with a credential sent as a bearer token.\nThere are two ways to obtain one and one permission model behind both. **An\nAPI key** is minted by a creator in Commune's settings. **An OAuth access\ntoken** is issued when a person completes the authorization code flow and\nclicks allow; the walkthrough is at\n[usecommune.dev/use-cases/build-an-integration](https://usecommune.dev/use-cases/build-an-integration).\n\nA credential is granted one or more of the newsletters its holder can act\non. On each of those it carries six independent permissions, one per family,\neach `none`, `read` or `write`:\n\n| Family | Covers |\n| --- | --- |\n| `content` | articles, the passages readers marked in them, threads, messages |\n| `audience` | subscribers, segments, the community roster |\n| `sending` | sending an article, senders, delivery attempts |\n| `insights` | engagement events and the metrics over them |\n| `settings` | the newsletter's configuration, its website domains, its team, its credentials |\n| `webhooks` | event destinations and the portal that edits them |\n\nEach operation names the family and the level it needs, as an OAuth scope\nsuch as `content:read`. `write` implies `read` **within its own family and\nnowhere else**: there is no hierarchy across families, so a credential that\nmay send your articles has no claim at all on your subscribers. An operation\na credential does not hold the family for answers `403` naming what it\nneeded and what the credential holds on that newsletter.\n\nPermissions are granted per newsletter, so the same credential can hold\n`content: read` on one and `audience: write` on another. They are also\n**bounded by their holder**: what a credential can do is what it was granted\nintersected with what the person it belongs to can do on that newsletter at\nthe moment of the request. Remove them from the team and the credential\nreaches nothing there on its very next call; demote them from admin to\neditor and it loses `settings: write`. There is nothing to revoke and no\ndelay.\n\n**Unpublished rows follow one extra rule.** A draft, an article whose send\ntime has not arrived, and a thread addressed to a segment are not secret,\nthey are unpublished, and the credentials that may see them are the ones\nthat may change the newsletter: those holding `write` in **any** family on\nit. A credential holding only `read` permissions sees the newsletter as it\nhas been published, and this document says so on each operation where it\nmakes a difference.\n\nA credential can also carry `account: read`, which reads the account it\nbelongs to: the profile behind it, and the teams, lists, saved articles and\nliked articles that belong to the person rather than to a newsletter. It is\na **separate axis**, not a seventh family. No newsletter grant implies it\nand it implies no newsletter grant, so a credential that reads a\nnewsletter's subscribers still cannot read its owner's own reading list. It\nis granted on the credential itself, so either kind can carry it, and one\nissued without it answers `403` at an operation that needs it however many\nnewsletters it reaches.\n\nAn OAuth authorization that asks only for `account:read` is granted no\nnewsletter, so it answers `403` at every operation that addresses one.\n\n## Rate limits\n\nEvery request is counted against the credential that made it, never against\nan address. Three budgets apply:\n\n* `general` counts every request.\n* `audience`, which is tighter, counts only the operations that return\n  subscriber or recipient email addresses.\n* `write`, equally tight, counts only the operations that change something.\n\nAn operation covered by one of the narrow budgets is charged to it and to\n`general`, and has to pass both.\n\nFrom the moment a credential resolves, every response carries\n`RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` for whichever\nbudget is closest to exhaustion, and `RateLimit-Policy` listing every budget\nthat applied. `RateLimit-Reset` is in seconds from now. A `429` additionally\ncarries `Retry-After`, and its `message` names the budget that refused:\nbeing refused by the `audience` budget still leaves the rest of the API\ncallable.\n\n`GET /rate-limit` reports every budget at once, which is what to read rather\nthan inferring the whole picture from the one budget the headers describe.\n\n## Writes\n\nAn operation that changes something is a `POST`, a `PATCH` or a `DELETE`,\nneeds `write` in its own family, and differs from a read in three ways.\n\n**It requires an `Idempotency-Key` request header.** Choose one value per\nchange you intend to make, and send that same value again if you have to\nretry. Commune records the answer your first attempt produced and replays\nit rather than making the change a second time; a replayed response carries\n`Idempotent-Replay: true` and is otherwise identical to the original. A key\nis remembered for 24 hours, per credential.\n\nReusing a key for a different request answers `409` rather than replaying\nthe wrong answer. Two requests count as the same request when the operation,\nthe path, the query string, the body and the contract version all match.\n\n**It is counted against the `write` rate limit budget.** See Rate limits\nabove.\n\n**It publishes an event**, carrying your credential in the envelope's\n`actor` and your `Idempotency-Key` in `idempotency_key`. That lets a\nconsumer tell a change your integration made from one a creator made in\nCommune, and collapse the events one retried write produced. If the same\nintegration also consumes events, the event your write publishes is\ndelivered back to you: skip the ones whose `idempotency_key` you issued, or\nyour integration will answer itself. See Webhooks.\n\n## Pagination\n\nCollections are cursor paginated. A response carries `data` plus a\n`pagination` object holding an opaque `next_cursor`. Pass it back as\n`?cursor=` to fetch the following page. There is no offset, limit-offset or\npage number, and a cursor is not a durable identifier.\n\n## Identifiers\n\nResources that have a page in Commune carry both a UUID `id` and a short,\nURL friendly `short_id`. Either value is accepted wherever a path parameter\nnames that resource.\n",
    "contact": {
      "name": "Commune",
      "url": "https://usecommune.com",
      "email": "support@usecommune.com"
    },
    "termsOfService": "https://usecommune.com/terms"
  },
  "servers": [
    {
      "url": "https://api.usecommune.com",
      "description": "Production. There is no separate sandbox host.\n"
    }
  ],
  "tags": [
    {
      "name": "Newsletters",
      "description": "A newsletter is the top level object in Commune. It owns its articles, its\nchat, its subscribers and its team. Everything else in this API hangs\noff one.\n"
    },
    {
      "name": "Team",
      "description": "Who may act on behalf of a newsletter: its owner, plus the members the\nowner added as admins, editors or guests.\n\nBoth sides of that edge are here. A newsletter's roster answers \"who is on\nthis team\" and needs `settings`. `GET /memberships` answers \"which teams\nis this account on\", which is the same membership read from the person\nrather than from the newsletter, and needs `account: read` instead:\nthe set of teams somebody is on is a fact about them.\n"
    },
    {
      "name": "Users",
      "description": "A person with a Commune account: the readers who join a community and the\nwriters who are credited on an article. Looked up by identifier or by\nusername, and only ever as a public profile: never an email address.\n\nThe one account read from the inside is the credential's own. `GET /me`\nis the same person as the profile above plus the address and verification\nstate that one withholds, and it sits here rather than under a heading of\nits own because it is the private view of exactly what this tag already\ndocuments. It needs no permission at all.\n"
    },
    {
      "name": "Search",
      "description": "One query across newsletters, articles, people and chat. Where a reader\nstarts who does not yet have an identifier for any of them.\n"
    },
    {
      "name": "Articles",
      "description": "An article is one thing a newsletter published: written in Commune and\nsent, or imported from the newsletter's provider. Two rules\ngate every article read and are described on each operation. First, an\narticle stamped with an audience is visible only to the newsletter's team\nand to subscribers holding one of its tags. Second, an article dated in\nthe future is invisible until that moment passes.\n\nAn article written in Commune can be created and edited here, its body sent\nand returned as Markdown, and moved through its life: sent to a test\naddress, queued for a time, taken back off the schedule, sent to the list,\nand re-attempted for the recipients a dispatch could not reach. An\nimported article is read only.\n\nWhat one reader did with an article is here too, from their side of it.\n`GET /saved-articles` and `GET /liked-articles` are the articles an account\nput aside and the articles it liked, across every newsletter it reads, and\nthey need `account: read` rather than `content`. Both obey the two rules\nabove, applied against the person rather than against a newsletter, so an\narticle they may no longer read leaves the page on its own.\n"
    },
    {
      "name": "Highlights",
      "description": "A highlight is a passage of an article a reader marked. It anchors a\ncomment to the exact sentence that prompted it.\n"
    },
    {
      "name": "Threads",
      "description": "A thread is a conversation inside a newsletter's community. Commune has no\nseparate posts or comments stack: a creator's broadcast, a reader's\nquestion and the discussion under an article are all threads in the same\nnewsletter scoped chat.\n"
    },
    {
      "name": "Messages",
      "description": "A message is a reply inside a thread, up to two levels deep. Reactions\nhang off a message.\n"
    },
    {
      "name": "Subscribers",
      "description": "Who receives a newsletter. Needs `audience`, and never a public\nsurface: a newsletter's list belongs to its creator.\n\n`GET /subscriptions` is that edge read from the other end, the lists one\naccount is on rather than the people on one list, and it needs\n`account: read` instead. It carries none of what a newsletter's own record\nof a subscriber carries: no address, no lifecycle status, none of the tags\nthe newsletter applied and nothing about how they were acquired.\n"
    },
    {
      "name": "Subscriber tags",
      "description": "A tag segments a newsletter's audience. Sending an article to a tag stamps\nthat article with an audience, which is what makes it invisible to everyone\noutside it. Named for the subscribers it is applied to, because a tag\ncalled `Tags` inside a document made of tags says nothing.\n\nApplying and removing a tag are writes, and they are grants and\nrevocations of access to whatever articles that segment was addressed to,\nnot only labels. Creating, renaming and retiring a tag are not operations\nhere yet.\n"
    },
    {
      "name": "Engagement",
      "description": "What Commune knows about one subscriber that a newsletter's email provider\ncannot answer: engagement scored across the inbox and the community\ntogether, and the raw event stream those scores are summed from. Row\nshaped and high cardinality, which is what a CRM or a re-engagement\nautomation reads. Needs `insights`, and part of the one read surface Commune\nmay put behind a plan.\n"
    },
    {
      "name": "Metrics",
      "description": "The rolled up numbers for a newsletter and for one article: headline stats\nfor a period, acquisition attribution, bucketed series for charting, and\none article's email performance beside its community response. What a\ndashboard reads, where Engagement is what an automation reads. Creator\nscope, and part of the one read surface Commune may put behind a plan.\n"
    },
    {
      "name": "Sends",
      "description": "A send is one dispatch of one article to a newsletter's list: when it\nstarted, when it finished, and the three numbers it finished on. Not to\nbe confused with Senders, one heading below: a sender is the address an\narticle goes out from and is configuration, a send is something that\nhappened.\n\nStarting one is an operation under Articles, because it is a moment in an\narticle's life. Reading what became of it is here, because a run is its own\nobject with its own identifier and one article can have more than one.\n\nThe same run is announced as a `send.completed` event, carrying the same\nthree numbers under the same names, and these operations are how a\nconsumer reads them back afterwards from the identifier that event\ncarried. Needs `sending`.\n"
    },
    {
      "name": "Senders",
      "description": "The addresses a newsletter sends from, and the state of the DNS that has\nto be in place for them to work. The sending half of the pair; Website\ndomains is the other. Needs `sending`.\n"
    },
    {
      "name": "Website domains",
      "description": "A creator's own domain pointed at their Commune site, so their community\nlives at their address rather than at ours. The same prove you own this\nhostname flow as Senders, pointed at the site rather than at the mail.\nNeeds `settings`: a website domain is how the newsletter is configured,\nnot how it sends.\n"
    },
    {
      "name": "Platform",
      "description": "The API's own machinery rather than any newsletter's data: the readiness\nprobe, what a credential has left of its rate limit budgets, what its\nnewsletter's plan allows, and the newsletter's API keys. A key can be\nlisted and revoked here but never created, so a stolen credential cannot\nmint itself a replacement.\n"
    },
    {
      "name": "Event delivery",
      "description": "Where a newsletter's events go, and how a creator changes it. Commune\nhands every event it publishes to a delivery service that owns fan out,\nretries, signing and the delivery log, and a destination is one place that\nservice sends them: an HTTPS endpoint, or a queue, stream or object store\nfor a consumer that would rather not run a web server.\n\nReading the list is an operation here, and so is reading the delivery\nattempt log: what was handed to which destination, what came back, and\nasking for one to be handed over again.\n\nChanging the destinations themselves is not. `portal-session` mints a link\ninto the delivery service's own portal, where a creator adds an endpoint,\ndisables one and rotates a signing secret. Asking for an attempt to be\nreplayed is the one write here, and is the same action as the portal's\nretry button.\n\nNeeds `webhooks` throughout, since a destination is a private endpoint of\nthe creator's, the list of them says which systems a newsletter is wired\ninto, and the attempt log says what those systems were told and when.\n"
    },
    {
      "name": "Webhooks",
      "description": "The events Commune pushes to a consumer, rather than the resources a\nconsumer pulls. Commune publishes state changes on 23 topics, each\ndelivered as one HTTPS POST to an endpoint the consumer registered.\n\nEvery message shares one envelope, so a consumer can route on `type` and\ndedupe on `id` without knowing anything about the specific event, and the\nsame values arrive as `Commune-Event-Type` and `Commune-Event-Id` headers\nso both can be read before the body is parsed.\n\nDelivery is at least once and unordered. A non-2xx response or a timeout\nis retried with backoff, so a consumer has to treat `id` as the dedupe key\nand tolerate replays. `occurred_at` is the ordering field, not arrival\ntime.\n\nTwo envelope fields say where a change came from rather than what\nchanged. `actor` names the credential when the change was made through\nthis API's write operations, and `idempotency_key` carries the key that\nwrite was made under. Both are `null` for a change made anywhere else,\nwhich is most of them.\n\n**If your consumer writes, read `idempotency_key` before you act.** A\nwrite through this API publishes an event, and that event is delivered to\nevery endpoint registered for the newsletter, including yours. A consumer\nthat reacts to events by writing therefore receives the echo of its own\nwrite, cannot tell it from a change somebody else made, and writes again.\n\nYou already hold what breaks the loop: you generated the key you sent on\nthe write, so keep it and skip any event whose `idempotency_key` is one of\nyours. `actor` is not the field for this. It names Commune's own id for\nyour credential, and no operation here tells you what that id is.\n\nRegistering an endpoint happens in the delivery portal, which\n`POST /newsletters/{newsletter}/portal-session` mints a link into. The\nendpoints already registered are readable at\n`GET /newsletters/{newsletter}/destinations`.\n\nWhat happened to a particular message is readable.\n`GET /newsletters/{newsletter}/delivery-attempts` lists every handover\nCommune made, filterable by the `event_id` a consumer reads off its own\n`Commune-Event-Id` header, so \"did that event reach me\" is answerable from\nboth sides of the same identifier.\n"
    }
  ],
  "security": [
    {
      "apiKey": []
    }
  ],
  "paths": {
    "/status": {
      "get": {
        "operationId": "getStatus",
        "summary": "Service status",
        "description": "Whether the API is serving, what it depends on to serve, and which\ncontract version it is currently on. Reaching it at all proves the\nprocess is up and routing; `status` and `dependencies` say whether it is\nup in a useful sense.\n\nEvery check is shallow. It proves that a dependency answers, not that it\nanswers correctly, so read a `down` as a reason to stop retrying and\nnever an `up` as a guarantee that a write elsewhere will land.\n\nThe answer is coarse: capability names and states, no vendor and no\nfree text, and no distinction between a dependency that failed its probe\nand one Commune cannot reach at all.\n\n**The one operation that takes no credential**, and the one that ignores\n`Commune-Version`. With no key to count against, the per-key budgets do\nnot apply and no `RateLimit-*` headers are returned. Whatever sits in\nfront of this service can still refuse a request, which is why `429`\nstays declared.\n",
        "tags": [
          "Platform"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "The service is serving traffic. Read `status` before trusting it to\nserve every operation: a `200` here reports a degradation rather\nthan hiding it.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ServiceStatus"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/status\n"
          }
        ]
      }
    },
    "/rate-limit": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        }
      ],
      "get": {
        "operationId": "getRateLimit",
        "summary": "Retrieve the calling credential's rate limit state",
        "description": "What this credential has left, on every budget that applies to it,\nmeasured after this request has been counted.\n\nThree budgets exist. `general` counts every request. `audience` counts\nonly the operations that return subscriber or recipient email addresses.\n`write` counts only the operations that change something. The two narrow\nbudgets are smaller than `general`, and the operations they cover are\ncharged to both and have to pass both, so a credential that has\nexhausted one of them can still call everything else.\n\nRead this rather than inferring the whole picture from the `RateLimit-*`\nheaders, which describe one budget at a time.\n\nThis operation is itself counted against the `general` budget.\n",
        "tags": [
          "Platform"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": []
          }
        ],
        "responses": {
          "200": {
            "description": "The current window, limit and remaining for this credential.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RateLimit"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/newsletters": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        }
      ],
      "get": {
        "operationId": "listNewsletters",
        "summary": "List newsletters",
        "description": "The newsletters your credential reaches.\n\nThis is the operation to call first. A credential is granted a list of\nnewsletters, and everything else in this API is addressed by an\nidentifier from that list: this is where those identifiers come from,\nand it is what an integration calls when it holds a credential and does\nnot yet know which newsletters it is for.\n\nMost credentials reach exactly one, and this answers one item for them.\nA credential granted several answers several. A credential whose owner\nhas lost their standing on a newsletter stops reaching it, and this\nanswers one item fewer, or an empty page rather than an error.\n\nIt is not a directory of Commune. There is no way to see a newsletter\nyou were not granted, here or anywhere else in this API, and a filter\nbelow narrows this list rather than searching outside it.\n\nOrdered newest first, and paged like every other collection.\n",
        "tags": [
          "Newsletters"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "esp",
            "in": "query",
            "required": false,
            "description": "Return only newsletters published through this provider. Repeat the\nparameter to accept several.\n",
            "schema": {
              "$ref": "#/components/schemas/Esp"
            }
          },
          {
            "name": "handle",
            "in": "query",
            "required": false,
            "description": "Return only the newsletter with this handle, **among the ones your\ncredential reaches**. Zero or one item.\n\nA handle is unique across Commune, but this is a filter and not a\nlookup: a handle your credential was not granted answers an empty\npage, exactly as it would if no such newsletter existed.\n\n`GET /newsletters/{newsletter}` also accepts a handle and answers\n`404` rather than an empty page, which is what you want when you are\nafter a single object.\n",
            "schema": {
              "type": "string",
              "maxLength": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of newsletters.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "The newsletters this credential reaches, and nothing\nelse. Usually one entry, because a credential is\nusually granted one newsletter; several when it was\ngranted several; none when its owner's standing on\nevery one of them has gone. The length of this page is\nnever a count of anything on Commune.\n",
                          "items": {
                            "$ref": "#/components/schemas/Newsletter"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "theKeysNewsletter": {
                    "summary": "A credential granted one newsletter, reading which one.",
                    "value": {
                      "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
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters?limit=20\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "getNewsletter",
        "summary": "Retrieve a newsletter",
        "description": "Read one newsletter by `id` or by `handle`. A newsletter has no\n`short_id`: its handle is the short, human readable identifier, and it\nis what the web profile at `/n/{handle}` resolves on.\n",
        "tags": [
          "Newsletters"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The newsletter.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Newsletter"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/newsletters/the-weekly \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/articles": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterArticles",
        "summary": "List a newsletter's articles",
        "description": "The newsletter's articles, most recently published first, ordered by\n`posted_at` descending with articles that never got a date last.\n\nTwo gates apply and neither can be turned off. An article stamped with\nan audience is returned only to a credential that may read that\naudience: the\nnewsletter's owner, an admin or editor, or a subscriber holding one of\nthe article's tags. An article whose `posted_at` is in the future is not\nreturned at all until that moment passes, so a scheduled article never\nleaks early through this collection.\n\n`content` is never included here, whatever `?fields=` asks for. An article\nbody is large enough that returning a page of them is the wrong default,\nso read it from `GET /articles/{article}`.\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Return only articles in this state. A credential holding only\n`read` permissions may ask for `sent` alone, since a draft or a\nfailed send is not published. Repeat the parameter to accept\nseveral.\n",
            "schema": {
              "$ref": "#/components/schemas/ArticleStatus"
            }
          },
          {
            "name": "imported",
            "in": "query",
            "required": false,
            "description": "`true` returns only articles imported from the newsletter's\nprovider, `false` only articles written in Commune. Omit\nfor both.\n",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "tag",
            "in": "query",
            "required": false,
            "description": "Return only articles stamped with this subscriber tag, by tag `id`.\nNeeds `content: read`, because the audience of an article is not public.\n",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of articles, each without `content`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "This page of the newsletter's articles, most recently\npublished first by `posted_at`, with articles that\nnever got a date last. Two gates remove rows before\nthe page is built, and neither is marked in the\nresponse: an article dated in the future is absent\nuntil that moment passes, and an article stamped with\nan audience is absent unless the credential may read\nthat audience. A page shorter than expected is those gates\nrather than an error. No entry carries `content`,\nwhatever `?fields=` asked for.\n",
                          "items": {
                            "$ref": "#/components/schemas/Article"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "twoRecentArticles": {
                    "summary": "An article written in Commune and an imported one, newest first, neither carrying a body.",
                    "value": {
                      "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"
                        },
                        {
                          "object": "article",
                          "id": "5b7a1d90-2c34-4e18-9f6b-8d0a1c2b3e4f",
                          "short_id": "q4Ts9wLm",
                          "slug": "the-week-we-stopped-chasing-opens",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "title": "The week we stopped chasing opens",
                          "preview_text": null,
                          "image_url": null,
                          "external_url": "https://example.com/p/the-week-we-stopped-chasing-opens",
                          "status": "sent",
                          "is_imported": true,
                          "posted_at": "2026-08-19T09:30:00Z",
                          "scheduled_for": null,
                          "authors": [
                            {
                              "object": "user",
                              "id": "usr_5Qw8Hn2vFd"
                            }
                          ],
                          "thread": null,
                          "stats": {
                            "likes": 61,
                            "comments": 9,
                            "highlights": 14
                          },
                          "created_at": "2026-08-26T20:21:09Z",
                          "updated_at": "2026-08-26T20:21:09Z"
                        }
                      ],
                      "pagination": {
                        "has_more": true,
                        "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/articles?status=sent&limit=10\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      },
      "post": {
        "operationId": "createArticle",
        "summary": "Create an article",
        "description": "Writes a new article and returns it. Needs `content: write`, and is a\nwrite.\n\nThe article's text goes in `content_markdown`, as Markdown: the same\nrendition `GET /articles/{article}` returns as `content_markdown` under\n`?expand=content`, so what you read back is what you send. HTML is not\naccepted. Commune parses it strictly: text it cannot read answers `400`\nnaming the line, and nothing is written, rather than guessing at a\ndocument you did not write and sending it to your list.\n\n**What you get is always a draft.** `status`, `posted_at` and\n`scheduled_for` are not properties of the request. A draft is invisible\non every reader surface, so nothing this operation does reaches anybody.\nIt goes out through Schedule an article\n(`POST /articles/{article}/schedule`) or Send an article to the list (`POST /articles/{article}/send`),\neach of which runs the six gates described on the second before its\nemail leaves Commune.\n\n**Send no body at all and Commune seeds one.** `{}` creates an empty\nuntitled draft: two blank lines and an editable unsubscribe line. Send a body and it is stored exactly as sent,\nwith nothing appended. The send operations refuse an article whose body\ncarries no unsubscribe mechanism and no postal address, so a body you\nintend to send should carry `{{ unsubscribe_url }}` and `{{ address }}`.\n\n**Only a newsletter Commune publishes.** A newsletter whose `esp` is\nanything but `commune` has its articles written elsewhere and mirrored\ninto Commune afterwards, so there is nothing here to create. That\nanswers `422` with the code `not_commune_newsletter`, whose `docs_url` is\n`https://usecommune.dev/errors/not_commune_newsletter`, the page on\nwhat the refusal means and how to move a newsletter onto Commune.\n\nPublishes no event. A draft has neither gone out nor been queued, and\n`status` is what says so until one of those happens.\n\n## The Markdown `content_markdown` takes\n\nHeadings, paragraphs, bold, italic, strikethrough, inline code, links,\nimages, blockquotes, bullet and ordered lists, fenced code blocks with\na language, tables and thematic breaks. A line ending in two spaces or\na backslash is a line break; a code fence without a language is stored\nwithout one rather than guessed at.\n\nMerge tags survive exactly as written. `{{ subscriber.first_name }}`\nand `{% if %}` are personalization rather than Markdown, so nothing\ninside a Liquid construct is escaped or read as formatting.\n\nRaw HTML is refused rather than passed through or dropped. Write a\nliteral `<` as `\\<`.\n\n## The five components\n\nFive things the editor can hold have no Markdown spelling, so they get\nan MDX-shaped syntax. The set is closed: any other tag name is a `400`.\n\n* `<Section>` ... `</Section>` wraps blocks in a styled band. Optional\n  `backgroundColor`, `textColor` (hex, with the `#`), `fontFamily`\n  (`sans`, `serif`, `mono`), `fontSize` (a number, in px) and\n  `textAlign` (`left`, `center`, `right`).\n* `<EmailOnly>` ... `</EmailOnly>` wraps blocks that belong in the\n  inbox and not on the web. This is where the unsubscribe line and the\n  mailing address go: on the website there is no subscriber, so the\n  link is dead and the address is noise. Commune seeds exactly this\n  into a draft created with no body.\n* `<Button href=\"...\">Label</Button>` is a call to action. `href` is\n  required; `alignment` is optional.\n* `<Socials items={[...]} />` is a row of linked platform icons. `items`\n  is required and is a JSON array of\n  `{\"platform\": \"...\", \"url\": \"...\", \"imageUrl\": null}`. Optional\n  `align`, `iconColor` and `iconBgColor`.\n* `<YouTube url=\"...\" />` is a video. The URL has to be one Commune can\n  read a video id out of (`youtube.com/watch?v=`, `youtu.be/`,\n  `youtube.com/shorts/` or `youtube.com/embed/`), because the email\n  shows a thumbnail built from that id rather than an iframe, which\n  every major email client strips.\n\nAttributes are written `name=\"value\"` or `name={json}`. A component\nthat holds nothing is written self-closing; one that holds content is\nopened and closed on their own lines.\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ArticleCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The article, as created: a draft, with the `id`, `short_id` and `slug`\nevery other operation addresses it by. Read it back with\n`GET /articles/{article}` to see the stored body.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Article"
                },
                "examples": {
                  "newDraft": {
                    "summary": "A draft with a title and a body, not yet scheduled.",
                    "value": {
                      "object": "article",
                      "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012",
                      "short_id": "Vn3Pq8Zt",
                      "slug": "what-we-learned-in-march",
                      "newsletter": {
                        "object": "newsletter",
                        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                      },
                      "title": "What we learned in March",
                      "preview_text": "The third one surprised us.",
                      "image_url": null,
                      "external_url": null,
                      "status": "draft",
                      "is_imported": false,
                      "posted_at": null,
                      "scheduled_for": null,
                      "authors": [
                        {
                          "object": "user",
                          "id": "usr_2Nf8Kq1pWc"
                        }
                      ],
                      "thread": null,
                      "stats": {
                        "likes": 0,
                        "comments": 0,
                        "highlights": 0
                      },
                      "created_at": "2026-09-18T10:22:04Z",
                      "updated_at": "2026-09-18T10:22:04Z"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/articles/{article}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/ArticlePath"
        }
      ],
      "get": {
        "operationId": "getArticle",
        "summary": "Retrieve an article",
        "description": "Read one article, including its `content`. This is the only operation\nthat returns a body.\n\nThe same two gates apply as on the list. An article stamped with an\naudience answers `404` to a credential that does not hold that\naudience, and a\nfuture dated article answers `404` until it goes live, including to the\nnewsletter's own team, so that a preview link cannot be shared early.\n\nFor an article written in Commune, `content` is the email rendered to HTML\nwith personalization placeholders resolved against an empty context, so\na merge tag never leaks as raw text. For an imported article it is the\nbody as it arrived from the provider.\n\n`?expand=content` adds `content_markdown`, the same body as Markdown.\nAsk for it when a model is going to read the article, and ask for\n`?fields=content_markdown` with it to leave the HTML behind entirely.\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The article, with `content`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ArticleWithContent"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/articles/k7Rm2xQp \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      },
      "patch": {
        "operationId": "updateArticle",
        "summary": "Edit an article",
        "description": "Changes an article that already exists. Needs `content: write`, and is a\nwrite.\n\n**Absent and null are different.** A property you leave out is left\nalone; a property you send as `null` is cleared. Send at least one\nproperty: an empty object answers `400`.\n\n**`content_markdown` replaces the article's text in full.** Send the\nwhole article, not just the part that changed. It can't be `null`: an\narticle always has text. It takes the same Markdown as Create an\narticle (`POST /newsletters/{newsletter}/articles`), which documents\nit, and is parsed as strictly: text Commune cannot read answers `400`\nnaming the line, and nothing is written.\n\n**This never moves the article's state.** There is no `status` here, no\n`posted_at`, no `scheduled_for`. Queueing, cancelling, sending and\nretrying are their own operations. Neither is the byline: who is\ncredited on an article cannot be changed here.\n\n## Which articles may be edited\n\nA draft, a scheduled article, and one whose send failed. Everything\nelse answers `422` with a message saying which case it is:\n\n* **being sent right now**: an edit mid send would reach part of the\n  list and not the rest. Wait for it to finish.\n* **sent**: the email cannot be recalled, so an edit would change only\n  the web page.\n* **archived**: unarchive it in Commune first.\n* **imported**: the body is a copy of what the newsletter's provider\n  published and cannot be edited here.\n\n**Only a newsletter Commune publishes.** Anything but a `commune`\nnewsletter has its articles written elsewhere, and answers `422` with\nthe code `not_commune_newsletter`, carrying a `docs_url` of\n`https://usecommune.dev/errors/not_commune_newsletter`.\n\nEditing the body clears the HTML `content` returns until the article is\nnext opened in Commune's editor. `content_markdown` is rendered from the\narticle itself and is correct immediately.\n\nPublishes no event. The article's `updated_at` is what says it changed.\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ArticleUpdateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The article, as it now stands. Without `content`: read it back with\n`GET /articles/{article}` when you want the stored body.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Article"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/articles/{article}/authors": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/ArticlePath"
        }
      ],
      "get": {
        "operationId": "listArticleAuthors",
        "summary": "List an article's authors",
        "description": "Everyone credited on the byline, in the order the creator arranged them.\n\nAn article whose author was never mapped to a Commune account returns an\nempty page rather than a placeholder person.\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of authors, in byline order.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "Everyone credited on this article's byline, in the\norder the creator arranged them rather than by time.\nUsually one entry; an imported article attributed to\nseveral people through the provider's creator field,\nor a co-signed Commune article, has more. Empty for an\narticle whose byline was never mapped to a Commune\naccount, which is an ordinary outcome for an imported\narticle rather than an error.\n",
                          "items": {
                            "$ref": "#/components/schemas/User"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "coSignedArticle": {
                    "summary": "Two people on one byline, in the order the creator arranged them.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "user",
                          "id": "usr_2Nf8Kq1pWc",
                          "username": "mira",
                          "display_name": "Mira Okafor",
                          "avatar": "https://cdn.example.com/avatars/mira.png"
                        },
                        {
                          "object": "user",
                          "id": "usr_5Qw8Hn2vFd",
                          "username": "sam",
                          "display_name": "Sam Ortega",
                          "avatar": null
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/articles/k7Rm2xQp/authors \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/articles/{article}/images": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        },
        {
          "$ref": "#/components/parameters/ArticlePath"
        }
      ],
      "post": {
        "operationId": "createArticleImage",
        "summary": "Store an image for an article",
        "description": "Stores an image for this article and answers with the URL it is served\nat, so whatever writes the article never needs somewhere of its own\nto host one. Needs `content: write`.\n\nTwo ways in, chosen by the body:\n\n- **`source_url`**: Commune downloads the image and stores a copy. For\n  an image you hold as a link, including a generated one on a link\n  that will expire. It has to be a public http or https URL on the\n  standard port; private and local network addresses are refused,\n  redirects included.\n- **`content_type`**: Commune answers with a one-time `upload` URL and\n  the image's final `url`. `PUT` the file's bytes to `upload.url` with\n  the `upload.headers`, before `upload.expires_at`. The bytes go\n  straight to storage, so a file on disk never has to pass through a\n  model or through this API. Until the upload is made, `url` answers\n  `404`.\n\nEither way the image is a JPEG, PNG, WebP or GIF of at most 10MB, and\nthe article itself is not changed: put the `url` in the body's\nMarkdown (`![alt](url)`) or in `image_url` with Update an article\n(`PATCH /articles/{article}`).\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ArticleImageRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The stored image, or for an upload, where it will be once the file\nis uploaded.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ArticleImage"
                },
                "examples": {
                  "imported": {
                    "summary": "Downloaded from `source_url` and stored.",
                    "value": {
                      "object": "article_image",
                      "url": "https://project.supabase.co/storage/v1/object/public/article-images/7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411/c0ffee00-1111-4a4a-8b8b-123456789abc/1790000000000-k3j9x2a1.png",
                      "content_type": "image/png",
                      "size_bytes": 48213,
                      "source_url": "https://example.com/chart.png",
                      "upload": null
                    }
                  },
                  "upload": {
                    "summary": "Ready for you to upload the file yourself.",
                    "value": {
                      "object": "article_image",
                      "url": "https://project.supabase.co/storage/v1/object/public/article-images/7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411/c0ffee00-1111-4a4a-8b8b-123456789abc/1790000000000-p0q8w7e2.jpg",
                      "content_type": "image/jpeg",
                      "size_bytes": null,
                      "source_url": null,
                      "upload": {
                        "url": "https://project.supabase.co/storage/v1/object/upload/sign/article-images/7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411/c0ffee00-1111-4a4a-8b8b-123456789abc/1790000000000-p0q8w7e2.jpg?token=eyJhbGciOi...",
                        "method": "PUT",
                        "headers": {
                          "Content-Type": "image/jpeg"
                        },
                        "max_bytes": 10485760,
                        "expires_at": "2026-10-01T18:00:00Z"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/articles/{article}/test-send": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        },
        {
          "$ref": "#/components/parameters/ArticlePath"
        }
      ],
      "post": {
        "operationId": "sendArticleTest",
        "summary": "Send a test copy of an article",
        "description": "Sends this article to addresses you name, or to yourself when you name\nnone, so somebody can look at it before the list does. Needs\n`sending: write`.\n\nLeave out `to` (or the whole body) and the copy goes to the person the\ncredential belongs to. Their address is not echoed back: the answer\nsays `sent_to_owner: true` and `recipients` is empty, because an email\naddress is revealed only by `GET /me`.\n\n**Nobody on the list receives anything.** No subscriber is touched, no\ndelivery is recorded, the article's status does not move and no event is\npublished. Run it as often as you like on the same draft: it changes\nnothing about the article, so a repeat is a second look rather than a\nsecond send.\n\nThe article is rendered the way a real send renders it, with three\ndifferences. Personalization placeholders are filled with example data\nrather than left as raw text. The unsubscribe link points at a page that\nexplains itself rather than at a live token, so a forwarded test cannot\nunsubscribe a real person. And the subject is prefixed, so a test is\nnever mistaken for the article.\n\n**Three of the six send gates apply here.** There has to be an article,\na verified sending address, and something in the body. The footer\nrequired on commercial email, the article's status and the image\nreachability check do not refuse a test, so a test is where you go to\nsee that the footer has gone. Send an article to the list (`POST /articles/{article}/send`) refuses on all six.\n\nTest sends count towards the same daily sending allowance real sends do.\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:write"
            ]
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TestSendRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "What was attempted and what the sending provider accepted. `sent`\nplus `failed` is always the number of addresses the copy went to,\nand an address named twice is only sent once.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TestSend"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/SendLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/articles/{article}/schedule": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        },
        {
          "$ref": "#/components/parameters/ArticlePath"
        }
      ],
      "post": {
        "operationId": "scheduleArticle",
        "summary": "Schedule an article",
        "description": "Queues this article to go out at a time you choose.\n\nOnly a draft or an already scheduled article can be scheduled, and\n`scheduled_for` has to be in the future. Scheduling an already scheduled\narticle moves it, which is how a send time is changed: there is no\nseparate reschedule.\n\n**A scheduled article is not published.** Its `status` becomes\n`scheduled` and `posted_at` stays null, so it stays invisible on every\nreader surface until it goes out. The newsletter's own credentials can\nread it, which is how you check what is queued.\n\n**Every gate Send an article to the list (`POST /articles/{article}/send`)\nruns is run here**, so a missing footer, an\nunreachable image, an unverified sending address or an empty body\nrefuses the schedule now rather than failing silently at six in the\nmorning.\n\nThe time is honoured to within a few minutes, not to the second.\nCommune dispatches queued articles in passes, so `scheduled_for` is the\nmoment from which an article may go out rather than the moment it will.\n\nPublishes `article.scheduled`.\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScheduleRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The article, as it now stands: `status` is `scheduled` and\n`scheduled_for` is the time it will go out from.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Article"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/SendLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/articles/{article}/unschedule": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        },
        {
          "$ref": "#/components/parameters/ArticlePath"
        }
      ],
      "post": {
        "operationId": "unscheduleArticle",
        "summary": "Cancel a scheduled article",
        "description": "Takes a queued article back off the schedule. Needs `sending: write`.\n\nThe article returns to `draft` and its send time is cleared, so nothing\nwill dispatch it until it is scheduled or sent again. It was never\nvisible to readers while it was queued, so nothing a reader sees\nchanges.\n\n**An article that is not scheduled answers `422`** rather than\nsucceeding quietly. `draft` and `sent` are both \"not scheduled\", and a\ncaller cancelling a send needs to know which one it is looking at: an\narticle that has already gone out cannot be recalled.\n\n**Publishes no event.** A consumer told the article was scheduled gets\nno second event saying it no longer is, so read `status` on the article\nto know where it stands.\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The article, back as a draft, with `scheduled_for` null.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Article"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/articles/{article}/send": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        },
        {
          "$ref": "#/components/parameters/ArticlePath"
        }
      ],
      "post": {
        "operationId": "sendArticle",
        "x-commune-destructive": "Emails the article to the list. Mail that has left cannot be recalled, so a client should confirm with a person before calling this.",
        "summary": "Send an article to the list",
        "description": "Sends this article to the newsletter's subscribers. Cannot be undone.\n\n**It answers before the article has gone out.** The article is queued\nfor immediate dispatch and the answer is `202` with the article as it\nnow stands: `status` is `scheduled` and `scheduled_for` is the moment it\nwas queued. Commune begins sending within a few minutes.\n\nWatch `send.completed` for the outcome and the counts, or `send.failed`\nif the dispatch broke. `article.published` fires when the article goes\nlive on the web. All three carry the credential that asked for the send\nand the idempotency key it was made under, so a consumer can tie them\nback to this call.\n\n**Six gates.** Four are simple: there has to be an article, a verified\nsending address, something in the body, and a status that can be sent\nfrom. An article that is already sending or sent answers `422`. The\nother two are worth knowing about before you call this:\n\n* **`missing_footer`** (`422`) when the body no longer carries an\n  unsubscribe link or a mailing address. Both are seeded into a draft as\n  ordinary content and can be edited away, and commercial email is\n  required to carry them. The error names which half is gone.\n* **`broken_images`** (`422`) when an image definitively will not load,\n  naming the URLs and why each one failed. An inbox fetches images when\n  the reader opens the article, so a rotted image is broken for\n  everybody and cannot be repaired after the send. Only a definite\n  answer refuses: a merely slow host is reported and never blocks. Set\n  `acknowledge_broken_images` to send anyway. That acknowledgement\n  covers the body as it currently reads and any edit clears it.\n\nAn article addressed to a tag goes only to the subscribers holding it.\nAn article with no subscribers to send to is still published: it goes\nlive on the web and reports zero recipients.\n\n**Failed deliveries are Commune's to follow up.** A recipient the\nsending provider turns away for a moment is retried automatically\nduring the send. One that still fails is followed up by Commune's team\nrather than re-sent blindly, because some of those may already have\nbeen delivered and a second copy is worse than a late one.\n\nPublishes `article.scheduled` when the article is queued, then\n`article.published` and `send.completed` or `send.failed` when the\ndispatch runs.\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:write"
            ]
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SendRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "The article is queued. `status` is `scheduled` and `scheduled_for` is\nthe moment it was queued; sending begins within a few minutes.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Article"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/SendLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/newsletters/{newsletter}/senders": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterSenders",
        "summary": "List a newsletter's sending addresses",
        "description": "The addresses this newsletter can send from, the default one first.\nNeeds `sending: read`.\n\nA `commune` newsletter is provisioned with one address\non a Commune owned domain, and the creator can add their own on top. An\naddress is only usable once its `verification_status` is `verified`,\nwhich depends on DNS the creator controls, so an integration that\nschedules a send should check this first.\n",
        "tags": [
          "Senders"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of sending addresses, default first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "The addresses this newsletter can send from, the\ndefault one first and the rest newest first. Every\naddress the newsletter holds is here whatever state\nit is in, so read `verification_status` before\ntreating one as usable: only `verified` can send, and\nan address waiting on DNS the creator controls can\nsit at `pending` indefinitely.\n",
                          "items": {
                            "$ref": "#/components/schemas/Sender"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "defaultAndOnePending": {
                    "summary": "The verified default address first, then one still waiting on DNS.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "sender",
                          "id": "d3e4f506-1728-4939-a4b5-c6d7e8f90a1b",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "kind": "custom",
                          "from_email": "hello@mail.example.com",
                          "from_name": "The Example Letter",
                          "reply_to_email": null,
                          "domain": "mail.example.com",
                          "local_part": "hello",
                          "verification_status": "verified",
                          "verification_records": [
                            {
                              "type": "TXT",
                              "name": "commune._domainkey.mail.example.com",
                              "value": "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDexample",
                              "priority": null,
                              "status": "verified"
                            }
                          ],
                          "verification_error": null,
                          "is_default": true,
                          "verified_at": "2026-08-26T16:00:12Z",
                          "last_checked_at": "2026-09-08T04:00:00Z",
                          "created_at": "2026-08-26T15:52:40Z",
                          "updated_at": "2026-08-26T16:00:12Z"
                        },
                        {
                          "object": "sender",
                          "id": "a7b8c9d0-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "kind": "custom",
                          "from_email": "notes@post.example.com",
                          "from_name": "The Example Letter",
                          "reply_to_email": "replies@example.com",
                          "domain": "post.example.com",
                          "local_part": "notes",
                          "verification_status": "pending",
                          "verification_records": [
                            {
                              "type": "TXT",
                              "name": "commune._domainkey.post.example.com",
                              "value": "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDexample2",
                              "priority": null,
                              "status": "pending"
                            }
                          ],
                          "verification_error": null,
                          "is_default": false,
                          "verified_at": null,
                          "last_checked_at": "2026-09-08T04:00:00Z",
                          "created_at": "2026-09-07T12:18:03Z",
                          "updated_at": "2026-09-08T04:00:00Z"
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/newsletters/the-weekly/senders \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/senders/{sender}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/SenderPath"
        }
      ],
      "get": {
        "operationId": "getSender",
        "summary": "Retrieve a sending address",
        "description": "Read one sending address, including the DNS records the creator has to\npublish and the reason verification last failed. Needs `sending: read`.\n",
        "tags": [
          "Senders"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The sending address.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Sender"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/senders/2f8b0c1a-5d34-4e7b-8f21-6a9c4b3d2e10 \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/domains": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterDomains",
        "summary": "List a newsletter's website domains",
        "description": "The creator's own domains pointed at this newsletter's Commune site.\nNeeds `settings: read`.\n\nTwo independent things have to be true for one to work, and they are\nreported separately. `verification_status` says whether ownership was\nproved and a certificate issued. `routing_ok` says whether the domain\nactually resolves to Commune, which a certificate says nothing about.\n",
        "tags": [
          "Website domains"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of website domains.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "The creator's own domains pointed at this\nnewsletter's Commune site, most recently added first.\nEmpty for a newsletter that reads on Commune's own\ndomain, which is the default rather than an error.\nTwo independent things have to be true for a domain\nto serve, and each entry reports them separately:\n`verification_status` is about ownership and the\ncertificate, `routing_ok` is about whether the\nhostname actually resolves to Commune.\n",
                          "items": {
                            "$ref": "#/components/schemas/Domain"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "oneLiveOnePending": {
                    "summary": "A domain serving, and a newer one whose certificate is fine but whose DNS is not.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "domain",
                          "id": "f5061728-3940-4b5c-8d6e-f90a1b2c3d4e",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "domain": "links.example.com",
                          "verification_status": "active",
                          "ssl_status": "active",
                          "dns_target": "saas.usecommune.com",
                          "verification_records": [],
                          "verification_error": null,
                          "routing_ok": false,
                          "routing_checked_at": "2026-09-08T03:30:00Z",
                          "verified_at": "2026-09-05T11:20:44Z",
                          "last_checked_at": "2026-09-08T03:30:00Z",
                          "created_at": "2026-09-05T11:02:10Z"
                        },
                        {
                          "object": "domain",
                          "id": "e4f50617-2839-4a4b-b5c6-d7e8f90a1b2c",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "domain": "blog.example.com",
                          "verification_status": "active",
                          "ssl_status": "active",
                          "dns_target": "saas.usecommune.com",
                          "verification_records": [],
                          "verification_error": null,
                          "routing_ok": true,
                          "routing_checked_at": "2026-09-08T03:30:00Z",
                          "verified_at": "2026-08-26T16:44:30Z",
                          "last_checked_at": "2026-09-08T03:30:00Z",
                          "created_at": "2026-08-26T16:31:07Z"
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/newsletters/the-weekly/domains \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/domains/{domain}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/DomainPath"
        }
      ],
      "get": {
        "operationId": "getDomain",
        "summary": "Retrieve a website domain",
        "description": "Read one website domain, including the records to publish and the last\nreachability probe. Needs `settings: read`.\n",
        "tags": [
          "Website domains"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The website domain.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Domain"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/domains/blog.example.org \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/search": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        }
      ],
      "get": {
        "operationId": "search",
        "summary": "Search your newsletters",
        "description": "Full text search across the newsletters this credential was granted:\nthe newsletters themselves, their articles and their chat, ranked by\nrelevance. Nothing outside those newsletters is searched.\n\nResults obey the same gates as the resource operations do. An article\nstamped with an audience, an article dated in the future, and a thread\nthat is not public are not returned to a credential that could not read\nthem directly, so search cannot be used to see around a permission.\n\nA query shorter than two characters returns an empty page rather than an\nerror, so a caller can send it on every keystroke.\n",
        "tags": [
          "Search"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "The query. Shorter than two characters returns an empty page.",
            "schema": {
              "type": "string",
              "maxLength": 200
            }
          },
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "Return only results of this kind. Repeat the parameter to accept\nseveral. Omit for all kinds.\n",
            "schema": {
              "$ref": "#/components/schemas/SearchResultType"
            }
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of results, most relevant first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "This page of hits, most relevant first by `score`.\nScores are comparable within one response and not\nacross responses or contract versions. A hit the\ncredential could not read directly is absent rather than\nredacted, so a page can be shorter than the number of\nmatches Commune holds and search cannot be used to\nsee around a permission. A query shorter than two\ncharacters returns an empty page rather than an\nerror.\n",
                          "items": {
                            "$ref": "#/components/schemas/SearchResult"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "mixedHits": {
                    "summary": "Three kinds of hit in one page, most relevant first.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "search_result",
                          "type": "article",
                          "title": "What newsletters get wrong about community",
                          "snippet": "The moderation load is the product, not a tax on it.",
                          "url": "https://example.com/p/what-newsletters-get-wrong-about-community",
                          "score": 0.94,
                          "resource": {
                            "object": "article",
                            "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
                          }
                        },
                        {
                          "object": "search_result",
                          "type": "newsletter",
                          "title": "The Example Letter",
                          "snippet": null,
                          "url": "https://usecommune.com/n/example-letter",
                          "score": 0.71,
                          "resource": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          }
                        },
                        {
                          "object": "search_result",
                          "type": "thread",
                          "title": null,
                          "snippet": "The bit about moderation load matched my experience exactly.",
                          "url": "https://example.com/t/k3n8qz",
                          "score": 0.48,
                          "resource": {
                            "object": "thread",
                            "id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9"
                          }
                        }
                      ],
                      "pagination": {
                        "has_more": true,
                        "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/search?q=onboarding&type=article\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/articles/{article}/highlights": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/ArticlePath"
        }
      ],
      "get": {
        "operationId": "listArticleHighlights",
        "summary": "List an article's highlights",
        "description": "The passages readers marked in this article, in the order they appear in\nthe body. Use `start_offset` and `end_offset` to place them, and `quote`\nwith `prefix` and `suffix` to re anchor when the body has since changed.\n\nHighlights are not attributed to a named person. Each carries an\n`owner_key`, an opaque value that is stable per highlighter within one\narticle and meaningless across articles, so a client can count distinct\nhighlighters and group one person's marks without learning who they are.\n",
        "tags": [
          "Highlights"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of highlights, in body order.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "The passages readers marked in this article, in body\norder by `start_offset`. No entry names a person:\n`owner_key` is stable per highlighter within this one\narticle and meaningless outside it, so count distinct\nvalues to count highlighters rather than expecting a\nuser. Empty for an article nobody has marked, which\nsays nothing about how widely it was read.\n",
                          "items": {
                            "$ref": "#/components/schemas/Highlight"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "twoMarksInBodyOrder": {
                    "summary": "A silent mark and one that started a discussion, ordered by position in the body.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "highlight",
                          "id": "e5f60718-2930-4b42-c3d4-e5f607182930",
                          "article": {
                            "object": "article",
                            "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
                          },
                          "quote": "Community is a distribution channel that answers back.",
                          "prefix": "and the thing nobody budgets for is that ",
                          "suffix": " That changes what a launch plan has to look like.",
                          "start_offset": 812,
                          "end_offset": 860,
                          "owner_key": "9b1d0e6a3c4f27b8",
                          "message": {
                            "object": "message",
                            "id": "c2d3e4f5-0617-4829-93a4-b5c6d7e8f90a"
                          },
                          "created_at": "2026-08-26T19:14:50Z"
                        },
                        {
                          "object": "highlight",
                          "id": "d4e5f607-1829-4a31-b2c3-d4e5f6071829",
                          "article": {
                            "object": "article",
                            "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
                          },
                          "quote": "The moderation load is the product, not a tax on it.",
                          "prefix": "which is why we keep saying that ",
                          "suffix": " Staffing it is the whole decision.",
                          "start_offset": 4218,
                          "end_offset": 4271,
                          "owner_key": "4f2a9c1e7b3d6a05",
                          "message": null,
                          "created_at": "2026-08-26T19:11:27Z"
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/articles/k7Rm2xQp/highlights \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/highlights/{highlight}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/HighlightPath"
        }
      ],
      "get": {
        "operationId": "getHighlight",
        "summary": "Retrieve a highlight",
        "description": "Read one highlight. It answers `404` whenever its article would, so a\nhighlight cannot be used to read a passage of an article the credential\nis not entitled to.\n",
        "tags": [
          "Highlights"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The highlight.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Highlight"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/highlights/7c1d9e4b-2a86-4f30-9b57-1e8d6c5a4f32 \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/tags": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterTags",
        "summary": "List a newsletter's tags",
        "description": "The newsletter's live audience tags, alphabetical, each with a count of\ncurrently subscribed holders. Needs `audience: read`: how a creator segments\ntheir audience is not public.\n\nRetired tags are excluded by default. A tag that an already sent article\nwas addressed to cannot be deleted outright, because the audience of a\nsent article does not change retroactively, so it is retired instead and\nkeeps its assignments. Pass `include_retired=true` to see those when\nrendering the audience of a historical article.\n",
        "tags": [
          "Subscriber tags"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "include_retired",
            "in": "query",
            "required": false,
            "description": "Include tags that were retired. They still resolve the audience of\narticles sent before they were retired.\n",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of tags, alphabetical.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "The newsletter's audience tags, alphabetical by\n`name`, each with a count of how many currently\nsubscribed people hold it. Retired tags are absent\nunless `include_retired=true` asked for them, so a\ntag missing from a default read may still be\nresolving the audience of an article that was already\nsent.\n",
                          "items": {
                            "$ref": "#/components/schemas/Tag"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "twoLiveTags": {
                    "summary": "Two live tags, alphabetical, with their current holder counts.",
                    "value": {
                      "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
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/tags?include_retired=true\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      },
      "post": {
        "operationId": "createTag",
        "summary": "Create a tag",
        "description": "Makes a new segment of this newsletter's audience. Needs\n`audience: write`, and is a write.\n\nThe tag starts empty. Nobody holds it and no article is addressed to\nit, so creating one grants nobody access to anything. People are put in\nit by Apply a tag to a subscriber\n(`POST /subscribers/{subscriber}/tags/{tag}`), or by Apply a tag to\nmany subscribers (`POST /tags/{tag}/subscribers`) for a list, which are\nthe calls that decide access, and an article is addressed to it when it\nis sent.\n\n`name` has to be unique among this newsletter's live tags. A name a\nlive tag already has answers `422`. A name freed by a retirement is\navailable again, and taking it does not disturb the retired tag, which\ngoes on resolving the audience of the articles it was addressed to.\n\nPublishes no event.\n",
        "tags": [
          "Subscriber tags"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TagCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The tag, as created. Empty, live, and addressable by `id`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tag"
                },
                "examples": {
                  "newSegment": {
                    "summary": "A segment with nobody in it yet.",
                    "value": {
                      "object": "tag",
                      "id": "cc33dd44-ee55-4f66-8a77-112233445566",
                      "newsletter": {
                        "object": "newsletter",
                        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                      },
                      "name": "Founding member",
                      "known_subscriber_count": 0,
                      "retired": false,
                      "retired_at": null,
                      "created_at": "2026-09-29T09:15:00Z"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/tags/{tag}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/TagPath"
        }
      ],
      "get": {
        "operationId": "getTag",
        "summary": "Retrieve a tag",
        "description": "Read one tag by `id`, retired or not. Needs `audience: read`, on a\nnewsletter the credential reaches.\n",
        "tags": [
          "Subscriber tags"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The tag.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tag"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/tags/4e2a7f19-8c53-4d6b-a710-3f9b2c8d5e64 \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      },
      "patch": {
        "operationId": "updateTag",
        "summary": "Rename a tag",
        "description": "Changes what a segment is called. Needs `audience: write`, and is a\nwrite.\n\n`name` is the only thing about a tag that can be changed, and changing\nit moves nothing else. The people holding the tag still hold it, and\nevery article addressed to it is still addressed to it, because an\narticle names a tag by `id` rather than by name. A rename is a label,\nnever a change of who may read what.\n\nThe new name has to be free among this newsletter's live tags; one a\nlive tag already has answers `422`.\n\n**A retired tag cannot be renamed**, and answers `422`. A retirement is\nthe surviving record of what an already sent article was addressed to,\nand renaming it would relabel an audience that has already gone out.\n\nPublishes no event.\n",
        "tags": [
          "Subscriber tags"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TagUpdateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The tag, as it now stands. `known_subscriber_count` is unchanged by\na rename, which is the point.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tag"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "delete": {
        "operationId": "deleteTag",
        "summary": "Delete a tag",
        "description": "Removes a tag, or retires it. Which of the two happened is in\n`outcome`, and the two are not the same act: one ends the tag, and the\nother does not. Needs `audience: write`, and is a write.\n\n**`deleted`.** No article was ever addressed to this tag, so it is\nremoved outright and its assignments go with it. Asking for it\nafterwards answers `404`. Creating a tag and changing your mind leaves\nnothing behind.\n\n**`retired`.** An article was addressed to this tag, so the tag is kept\nand marked retired instead, because the audience of an article that has\nalready been sent does not change retroactively. That has four\nconsequences, and the first is the one to read twice:\n\n* **Everyone holding the tag goes on holding it, and goes on being able\n  to read every article it was addressed to**, on the web as well as in\n  the inbox, evaluated live on every read rather than settled at send\n  time. `tag.known_subscriber_count` in the response is what it was, not\n  zero. **Retiring a tag revokes nobody's access to anything.**\n* It stops being offered. It is absent from the tag list unless\n  `include_retired=true` asks for it, and no new holder can be added,\n  because Apply a tag to a subscriber refuses a retired tag.\n* It stays addressable. `GET /tags/{tag}` resolves it either way, so a\n  client rendering the audience of an article sent last year still\n  finds the name behind the identifier.\n* Its name is free for a new tag to take.\n\n**If what you want is to take access away, take the tag off the people\nholding it** with Take a tag off a subscriber\n(`DELETE /subscribers/{subscriber}/tags/{tag}`), one subscriber at a\ntime. That operation works on a retired tag for exactly this reason.\nDeleting the tag is not that operation and cannot be made into it.\n\n**Retirement is final.** Deleting a tag that is already retired answers\n`200` with the retirement it already made and never removes the row:\nthe holders a retirement kept are the ones still deciding who may read\nwhat the tag was addressed to.\n\nPublishes no event.\n",
        "tags": [
          "Subscriber tags"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "What became of the tag. Read `outcome` before anything else: a\n`retired` tag is still deciding who may read the articles it was\naddressed to.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TagDeletion"
                },
                "examples": {
                  "removedOutright": {
                    "summary": "No article was addressed to it, so it is gone and so are its assignments.",
                    "value": {
                      "object": "tag_deletion",
                      "id": "cc33dd44-ee55-4f66-8a77-112233445566",
                      "outcome": "deleted",
                      "tag": null
                    }
                  },
                  "retiredInstead": {
                    "summary": "An article was addressed to it, so it is kept and its 214 holders keep their access to that article.",
                    "value": {
                      "object": "tag_deletion",
                      "id": "aa11bb22-cc33-4d44-8e55-ff6677889900",
                      "outcome": "retired",
                      "tag": {
                        "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": true,
                        "retired_at": "2026-09-29T09:15:00Z",
                        "created_at": "2025-06-11T08:45:00Z"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/tags/{tag}/subscribers": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/TagPath"
        }
      ],
      "post": {
        "operationId": "addTagSubscribers",
        "summary": "Apply a tag to many subscribers",
        "description": "Puts up to 500 subscribers in this segment in one request, and says\nwhat became of each. Needs `audience: write`, and is a write.\n\n**Use this when you have a list**, such as a weekly job tagging this\nweek's superfans from `GET /newsletters/{newsletter}/insights`. It is\none request against your rate limit however many subscribers it\nnames, where Apply a tag to a subscriber\n(`POST /subscribers/{subscriber}/tags/{tag}`) is one request per\nsubscriber. Use that one for a single subscriber: it answers `404` for\nan id that is not there, which is the clearer answer when there is\nonly one.\n\n**A tag decides what a person can read, not only who receives what.**\nAn article addressed to a tag is readable by the people holding it and\nby nobody else, on the web as well as in the inbox. Applying one grants\neach subscriber named access to every article that segment was ever\naddressed to, including articles sent before this call.\n\n**`subscribers` holds 1 to 500 subscriber ids**, the `id` of each\n`Subscriber`. A list that is empty, longer than 500, or holds anything\nthat is not a UUID answers `400` and changes nothing. An id repeated in\nthe list counts once and is reported once. The 500 counts the list as\nsent, repeats included.\n\n**Outcomes are per subscriber, not all or nothing.** Every id in the\nlist comes back in exactly one of three lists:\n\n* `tagged`: holds the tag now and did not before.\n* `already_tagged`: held it already. Nothing changes for them, which\n  is a success, exactly as it is for the single operation.\n* `not_found`: not a subscriber of the tag's newsletter. Nothing is\n  written for them and the rest of the list is unaffected. An id from\n  another newsletter and an id that exists nowhere are reported the\n  same way. Retrying them will not help: check where the ids came from.\n\nThe request is refused as a whole only for something true of the whole\nrequest. **A retired tag answers `422`** and tags nobody: a retirement\nstops a segment gaining members while it goes on deciding who may read\nthe articles it was addressed to. A tag this credential cannot reach\nanswers `404`.\n\n**Retrying is safe.** Send the same `Idempotency-Key` and the same list\nand you get the first answer back, replayed, with nothing done twice.\nA new key with the same list is a new request: everyone the first one\ntagged comes back in `already_tagged`, and nothing is published again.\n\n**The response is the tag and three lists of ids**, never a\n`Subscriber`, so this operation never puts an email address in a\nresponse.\n\nPublishes `subscriber.tagged` with `direction: assigned` once for each\nsubscriber in `tagged`, each its own event, all carrying this request's\n`actor` and `idempotency_key`. Nothing is published for\n`already_tagged` or `not_found`. The events are written in the same\ntransaction as the tags, so they exist if and only if the tags do.\n",
        "tags": [
          "Subscriber tags"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TagAssignmentRequest"
              },
              "examples": {
                "thisWeeksSuperfans": {
                  "summary": "Three subscribers to put in the Superfans segment.",
                  "value": {
                    "subscribers": [
                      "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "44556677-8899-4aa1-b2c3-d4e5f6071829",
                      "9f1e2d3c-4b5a-4c6d-8e7f-0a1b2c3d4e5f"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "What became of each subscriber, and the tag as it now stands.\n`tagged`, `already_tagged` and `not_found` together hold every id\nthe request named, once each, in the order they were sent.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TagAssignment"
                },
                "examples": {
                  "mixedOutcomes": {
                    "summary": "One newly tagged, one who already held the tag, and one id that is not a subscriber of this newsletter.",
                    "value": {
                      "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"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/newsletters/{newsletter}/members": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterMembers",
        "summary": "List a newsletter's team",
        "description": "Everyone who can act on behalf of the newsletter, oldest membership\nfirst. The owner is included as a synthetic member with the `owner`\nrole, so a client does not have to fold `newsletter.owner` in by hand.\n\nNeeds `settings: read`. Pending invitations are not members and are not returned\nhere.\n",
        "tags": [
          "Team"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "role",
            "in": "query",
            "required": false,
            "description": "Return only members with this role. Repeat the parameter to accept\nseveral.\n",
            "schema": {
              "$ref": "#/components/schemas/MemberRole"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of team members.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "Everyone who can act on behalf of the newsletter,\noldest membership first. The owner is the first entry\non the first page and appears on no other: there is\nno membership row behind it, so it is a synthetic\nmember whose `id` is the newsletter's `id` prefixed\nwith `owner_` and whose `created_at` is `null`.\nPending invitations are not members and are absent,\nso this is who can act today rather than who was\nasked.\n",
                          "items": {
                            "$ref": "#/components/schemas/Member"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "ownerThenTeam": {
                    "summary": "The synthetic owner entry first, then the stored memberships oldest first.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "member",
                          "id": "owner_7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "user": {
                            "object": "user",
                            "id": "usr_2Nf8Kq1pWc"
                          },
                          "role": "owner",
                          "created_at": null
                        },
                        {
                          "object": "member",
                          "id": "6d7e8f90-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "user": {
                            "object": "user",
                            "id": "usr_5Qw8Hn2vFd"
                          },
                          "role": "editor",
                          "created_at": "2025-11-18T09:25:00Z"
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/members?role=editor\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/subscribers": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterSubscribers",
        "summary": "List a newsletter's subscribers",
        "description": "The newsletter's subscribers, most recent first. Needs\n`audience: read`, and returns email addresses, so a credential that can\ncall this holds the newsletter's list.\n\nThis is Commune's own record of the audience. For a `commune`\nnewsletter it is the source of truth. For a newsletter\nconnected to an outside provider it is a partial cache of that\nprovider's list, filled in by imports and by people who joined through\nCommune, and it is not the newsletter's subscriber count of record. Ask\nthe provider for that number, and do not present a count derived from\nthis collection as a total.\n",
        "tags": [
          "Subscribers"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Return only subscribers in this state. Defaults to `subscribed`.\nRepeat the parameter to accept several.\n",
            "schema": {
              "$ref": "#/components/schemas/SubscriberStatus"
            }
          },
          {
            "name": "tag",
            "in": "query",
            "required": false,
            "description": "Return only subscribers holding this tag, by tag `id`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "source",
            "in": "query",
            "required": false,
            "description": "Return only subscribers whose `source` is this. `commune` is the\npeople who subscribed through Commune, `imported` the rows that came\nfrom the newsletter's provider or a file, and `unknown` the ones\nCommune recorded before it kept the answer. Omit for all three.\n\nFiltered on the same value the field reports, so a page can never be\nmissing a subscriber the field would have labelled this way.\n",
            "schema": {
              "type": "string",
              "enum": [
                "commune",
                "imported",
                "unknown"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of subscribers, most recently added first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "This page of the newsletter's subscribers, most\nrecently added first, each carrying the address the\nnewsletter reaches that person at. Defaults to\n`status=subscribed`, so somebody who left is absent\nuntil `?status=` asks for them. For a newsletter\nconnected to an outside provider these rows are\nCommune's partial cache of that provider's list,\nfilled in by imports and by people who joined through\nCommune, so the length of this collection is not the\nnewsletter's subscriber count and must not be\npresented as one.\n",
                          "items": {
                            "$ref": "#/components/schemas/Subscriber"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "oneAccountOneAddress": {
                    "summary": "A subscriber with a Commune account behind them, and one that is an address alone.",
                    "value": {
                      "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_2Nf8Kq1pWc"
                          },
                          "email": "reader@example.com",
                          "status": "subscribed",
                          "source": "commune",
                          "tags": [
                            {
                              "object": "tag",
                              "id": "aa11bb22-cc33-4d44-8e55-ff6677889900"
                            }
                          ],
                          "created_at": "2026-08-26T12:20:05Z",
                          "synced_at": null
                        },
                        {
                          "object": "subscriber",
                          "id": "44556677-8899-4aa1-b2c3-d4e5f6071829",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "user": null,
                          "email": "quiet@example.com",
                          "status": "subscribed",
                          "source": "imported",
                          "tags": [],
                          "created_at": "2026-08-26T20:03:41Z",
                          "synced_at": "2026-09-07T02:15:00Z"
                        }
                      ],
                      "pagination": {
                        "has_more": true,
                        "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/subscribers?status=subscribed&limit=100\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/subscribers/{subscriber}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/SubscriberPath"
        }
      ],
      "get": {
        "operationId": "getSubscriber",
        "summary": "Retrieve a subscriber",
        "description": "Read one subscriber by `id`. Needs `audience: read`, on a newsletter\nthe credential reaches. The same person subscribing to two newsletters\nis two subscribers, and neither newsletter sees the other's.\n",
        "tags": [
          "Subscribers"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The subscriber.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Subscriber"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/subscribers/b81f3d27-6e40-4a95-8c13-5d7e2f0a9b64 \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/subscribers/{subscriber}/tags/{tag}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        },
        {
          "$ref": "#/components/parameters/SubscriberPath"
        },
        {
          "$ref": "#/components/parameters/TagPath"
        }
      ],
      "post": {
        "operationId": "addSubscriberTag",
        "summary": "Apply a tag to a subscriber",
        "description": "Puts this subscriber in this segment. Needs `audience: write`.\n\nOne subscriber per request. To tag a list of them, up to 500 at a\ntime, use Apply a tag to many subscribers\n(`POST /tags/{tag}/subscribers`), which costs one request however many\nit names and reports what became of each.\n\n**A tag decides what a person can read, not only who receives what.** An\narticle addressed to a tag is readable by the people holding it and by\nnobody else, on the web as well as in the inbox. Applying one grants\naccess to every article that segment was ever addressed to, including\narticles sent before this call. Removing one revokes that access\nimmediately.\n\nApplying a tag the subscriber already holds succeeds, changes nothing\nand publishes no event.\n\n**A retired tag answers `422`.** A retirement stops a segment gaining\nmembers while it goes on deciding who may read the articles it was\naddressed to. Removing a retired tag still works, since taking a tag off\nonly ever narrows what somebody can read.\n\n**The response is the tag, not the subscriber**, so this operation never\nputs an email address in a response.\n\nPublishes `subscriber.tagged` with `direction: assigned`, unless the\nsubscriber already held the tag.\n",
        "tags": [
          "Subscriber tags"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The tag, as it now stands. `known_subscriber_count` includes this\nsubscriber if they are currently subscribed.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tag"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "delete": {
        "operationId": "removeSubscriberTag",
        "summary": "Take a tag off a subscriber",
        "description": "Removes this subscriber from this segment. Needs `audience: write`.\n\nA tag decides what a person can read, so taking one off revokes this\nperson's access to every article that segment was addressed to, with\neffect from the next page they load.\n\nRemoving a tag the subscriber does not hold succeeds, changes nothing\nand publishes no event.\n\n**Works on a retired tag**, where applying one does not: taking a tag\noff can only ever narrow what somebody can read.\n\nPublishes `subscriber.tagged` with `direction: removed`, unless the\nsubscriber did not hold the tag.\n",
        "tags": [
          "Subscriber tags"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The tag, as it now stands.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tag"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/newsletters/{newsletter}/threads": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterThreads",
        "summary": "List a newsletter's threads",
        "description": "The conversations in this newsletter's community, most recently active\nfirst, ordered by `last_activity_at` descending.\n\nA credential holding only `read` permissions sees threads whose\n`visibility` is `public`. One holding `write` in any family on the\nnewsletter also sees `subscribers` and `paid` threads, which are the\nones a reader has to have joined the newsletter to read.\n\nThreads opened by an article are included and carry `is_article_thread`\nas `true`. Filter them out with `is_article_thread=false` when you want\nonly what people started themselves.\n",
        "tags": [
          "Threads"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "visibility",
            "in": "query",
            "required": false,
            "description": "Return only threads with this placement. Asking for anything other\nthan `public` with a credential that holds only `read` permissions\nanswers `403`. Repeat the parameter to accept several.\n",
            "schema": {
              "$ref": "#/components/schemas/ThreadVisibility"
            }
          },
          {
            "name": "is_article_thread",
            "in": "query",
            "required": false,
            "description": "`true` returns only the discussions Commune opened under an article,\n`false` only the threads people started themselves. Omit for both.\n",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of threads.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "This page of the community's conversations, most\nrecently active first by `last_activity_at`. What is\non it depends on the credential: one holding only\n`read` permissions sees threads whose `visibility` is\n`public`, and one holding `write` in any family also\nsees `subscribers` and `paid` ones, so the same\ncollection read with two credentials is two different\nlengths and neither is wrong. Discussions Commune\nopened under an article are included and carry\n`is_article_thread` as `true`.\n",
                          "items": {
                            "$ref": "#/components/schemas/Thread"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "withWritePermission": {
                    "summary": "A credential holding `write`, so an article discussion and a subscribers-only thread are both here.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "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": "subscribers",
                          "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"
                        },
                        {
                          "object": "thread",
                          "id": "c3d4e5f6-0718-4920-a1b2-c3d4e5f60718",
                          "short_id": "t9m4hx",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "author": {
                            "object": "user",
                            "id": "usr_5Qw8Hn2vFd"
                          },
                          "content": "Open thread: what did you cut from your publishing week this month?",
                          "media": [
                            {
                              "url": "https://cdn.example.com/chat/t9m4hx/whiteboard.png",
                              "type": "image/png",
                              "thumbnail": "https://cdn.example.com/chat/t9m4hx/whiteboard-thumb.png"
                            }
                          ],
                          "visibility": "public",
                          "is_article_thread": false,
                          "article": null,
                          "reply_count": 4,
                          "view_count": 318,
                          "created_at": "2026-08-26T18:40:03Z",
                          "updated_at": "2026-08-26T21:55:09Z",
                          "edited_at": null,
                          "last_activity_at": "2026-08-26T21:55:09Z"
                        }
                      ],
                      "pagination": {
                        "has_more": true,
                        "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/threads?visibility=public\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      },
      "post": {
        "operationId": "createThread",
        "summary": "Start a thread",
        "description": "Opens a new conversation in the newsletter's community and returns it.\nNeeds `content: write`.\n\nThe thread is written by the person the credential belongs to, the\nsame person `createArticle` credits, and readers see it exactly as if\nthey had posted it in Commune. Subscribers are notified the way they\nare for any new thread, and an `@username` in the text mentions that\nperson.\n\n`content` is Markdown, as readers write it in Commune: plain text with\nlight formatting, up to 5,000 characters. It is stored as sent. It can\nbe left out when `media` attaches something, so a photo with no\ncaption is a thread; a body with neither answers `400`.\n\n`visibility` decides where the thread can be read. `subscribers`, the\ndefault, keeps it inside the newsletter. `public` also puts it on\nCommune's global feed in the same write, which is what\nPut a thread on the global feed (`POST /threads/{thread}/publish`) does\nto a thread that already exists.\n\nPublishes `thread.created`, and `thread.published` with `source`\n`created_public` when the thread is created public.\n",
        "tags": [
          "Threads"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ThreadCreateRequest"
              },
              "examples": {
                "question": {
                  "summary": "A question for the community, kept inside the newsletter.",
                  "value": {
                    "content": "What should the next issue dig into? Reply with one topic."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The thread, as created. It has no replies yet.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Thread"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/threads/{thread}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/ThreadPath"
        }
      ],
      "get": {
        "operationId": "getThread",
        "summary": "Retrieve a thread",
        "description": "Read one thread and its opening message. The replies are a separate\ncollection at `GET /threads/{thread}/messages`, so a busy thread does\nnot make this response unbounded.\n\nA thread opened by an article inherits that article's audience: if the\narticle is not readable by this credential, neither is its thread.\n",
        "tags": [
          "Threads"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The thread.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Thread"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/threads/b3Xn8kTw \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/threads/{thread}/publish": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        },
        {
          "$ref": "#/components/parameters/ThreadPath"
        }
      ],
      "post": {
        "operationId": "publishThread",
        "summary": "Put a thread on the global feed",
        "description": "Promotes a thread from the newsletter's own space to Commune's global\nfeed, where anyone can read it. Needs `content: write`. One thread at a\ntime: there is no automatic promotion and no bulk form.\n\n**It is not reversible through this API.** A thread can be taken back\noff the feed in Commune itself, but no operation here does it. Treat the\npromotion as a decision rather than a toggle.\n\nA thread that is already public is a success rather than a conflict,\nchanges nothing, and publishes no event.\n\nReplies are not threads and answer `422`. A reply is exactly as readable\nas the thread it is in, so promote the thread instead.\n\nTakes no body: there is only one visibility this moves a thread to.\n\nPublishes `thread.published`, carrying the visibility the thread had\nbefore, unless it was already public.\n",
        "tags": [
          "Threads"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The thread, now `public`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Thread"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/threads/{thread}/messages": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/ThreadPath"
        }
      ],
      "get": {
        "operationId": "listThreadMessages",
        "summary": "List a thread's messages",
        "description": "Every reply in the thread, oldest first, flattened. A reply to a reply\nis in the same page as its parent and points at it through `parent`, so\na client rebuilds the two level tree without a second request.\n\nDeleted messages are omitted rather than tombstoned.\n",
        "tags": [
          "Messages"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of messages, oldest first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "Every reply in this thread, oldest first and\nflattened rather than nested: a reply to a reply sits\nin the same page as its parent and points at it\nthrough `parent`, so a client rebuilds the two level\ntree without a second request and `depth` says which\nlevel an entry is on. Deleted replies are omitted\nrather than tombstoned. Every entry inherits the\nthread's own visibility and none carries one of its\nown.\n",
                          "items": {
                            "$ref": "#/components/schemas/Message"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "aReplyAndAReplyToIt": {
                    "summary": "Two levels in one flat page, the deeper one pointing at its parent.",
                    "value": {
                      "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"
                          },
                          "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"
                          },
                          "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
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/threads/b3Xn8kTw/messages \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      },
      "post": {
        "operationId": "createMessage",
        "summary": "Reply in a thread",
        "description": "Posts a reply in a thread and returns it. Needs `content: write`, and\nis a write.\n\nThe reply is written by the person the credential belongs to, and the\npeople in the thread are notified the way they are for any reply.\nIt needs `content`, `media`, or both.\n\nLeave `parent` out to reply to the thread itself. Name a reply in\n`parent` to reply to it: a conversation is two levels deep, so the\nparent has to be a direct reply to the thread, and a reply to a reply\nto a reply answers `422`. `quoted` optionally quotes the thread or any\nmessage in it. Both take a message's `id` or `short_id`, and both have\nto be in this thread.\n\nA reply has no visibility of its own: it is exactly as readable as its\nthread. A locked thread takes no replies and answers `409`.\n\nPublishes `message.created`.\n",
        "tags": [
          "Threads"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MessageCreateRequest"
              },
              "examples": {
                "reply": {
                  "summary": "A reply to the thread itself.",
                  "value": {
                    "content": "Good question. I will cover it next week."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The reply, as created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Message"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/messages/{message}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/MessagePath"
        }
      ],
      "get": {
        "operationId": "getMessage",
        "summary": "Retrieve a message",
        "description": "Read one reply. A message inherits the visibility of the thread it sits\nin, so this answers `404` whenever the thread would.\n",
        "tags": [
          "Messages"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Message"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/messages/p9Wd4vHs \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/me": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        }
      ],
      "get": {
        "operationId": "getMe",
        "summary": "Retrieve the authenticated account",
        "description": "The account this credential belongs to: the public profile\n`GET /users/{user}` returns, plus the email address and verification\nstate that profile withholds.\n\n**Any credential this API accepts can call it**, an API key included,\nand no permission is required. `GET /memberships`,\n`GET /subscriptions`, `GET /saved-articles` and `GET /liked-articles`\nare different and do need `account: read`.\n",
        "tags": [
          "Users"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The authenticated account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Me"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/memberships": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        }
      ],
      "get": {
        "operationId": "listMemberships",
        "summary": "List this account's memberships",
        "description": "Every newsletter this account has a place on, and the role it holds\nthere. It answers \"which teams am I on\";\n`GET /newsletters/{newsletter}/members` answers \"who is on this team\"\nand needs `settings: read` instead.\n\n**Needs `account: read`**, which either an API key or an OAuth token can\ncarry. A credential without it is refused whatever newsletters it\nreaches. One with it reads the account it belongs to and no other:\nthere is no parameter here in which to name a person.\n\nA row can name a newsletter no other operation will let this credential\naddress. Each row is the person's own membership and\nsays nothing about that newsletter beyond an identifier, nor anything\nabout anybody else on its team.\n\nThe owner of a newsletter appears here with the `owner` role, the same\nmembership `GET /newsletters/{newsletter}/members` emits.\n\n`newsletter` is a reference, and `?expand=newsletter` replaces it with a\n`NewsletterSummary`: handle, name, description and artwork, all of which\nthat newsletter's own page already shows anybody. Expanding widens\nnothing else, so a newsletter this credential holds no grant on stays\nunreadable through `GET /newsletters/{newsletter}`.\n",
        "tags": [
          "Team"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "account:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of memberships, oldest first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "Every newsletter this account has a place on the team\nof, oldest membership first, with the role it holds\nthere. A newsletter this account owns appears with\nthe `owner` role and a `null` `created_at`, the same\nsynthetic membership a newsletter's own team roster\nemits. Each `newsletter` is a reference unless\n`?expand=newsletter` asks for the public summary, so\ntreat the identifier as something to match against\nnewsletters the client already holds rather than as\nsomething to follow: holding the account axis grants\nno standing on the newsletters it names.\n",
                          "items": {
                            "$ref": "#/components/schemas/Membership"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "ownerAndEditor": {
                    "summary": "One newsletter this person owns, and one they were added to as an editor.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "membership",
                          "id": "owner_7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "role": "owner",
                          "created_at": null
                        },
                        {
                          "object": "membership",
                          "id": "7e8f9012-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "2c8d4e10-9b3a-4f52-8e71-5d0c6b7a8e93"
                          },
                          "role": "editor",
                          "created_at": "2026-02-14T16:08:21Z"
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/subscriptions": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        }
      ],
      "get": {
        "operationId": "listSubscriptions",
        "summary": "List the newsletters this account subscribes to",
        "description": "Every newsletter this account is subscribed to, most recently\nsubscribed first.\n\n**Needs `account: read`**, which either an API key or an OAuth token can\ncarry. A row can name a newsletter no other operation will let this\ncredential address, and says nothing about it beyond an identifier.\n\nOnly live subscriptions appear. Commune keeps the record when somebody\nleaves and moves its status instead, so an unsubscribed, bounced or\ncomplained record is not returned here.\n\nA subscription Commune learned from a newsletter's email provider is\nreturned beside one made in Commune. What is not returned is the half of\nthat record belonging to the newsletter rather than to the reader: the\naddress the provider held, the provider's own identifier for this\nperson, the tags the newsletter has applied, and how they were acquired.\n\nRead the page as Commune's record of what this person reads rather than\na complete one. Commune's subscriber records are a partial cache of each\nnewsletter's provider list, so a newsletter whose provider is not\nconnected to Commune is missing from it, and an unsubscribe made at the\nprovider can take until the next reconciliation to disappear.\n\n`newsletter` is a reference, and `?expand=newsletter` replaces it with a\n`NewsletterSummary`, under the rules `GET /memberships` sets out.\n",
        "tags": [
          "Subscribers"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "account:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of subscriptions, most recently subscribed first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "Every newsletter this account currently subscribes\nto, most recently subscribed first. Only live\nsubscriptions are here: Commune keeps the record when\nsomebody leaves and moves its status instead, so an\nunsubscribed, bounced or complained record is absent\nrather than present carrying a status. Read the page\nas Commune's record of what this person reads rather\nthan a complete one, and treat each `newsletter` as\nan identifier to match rather than one to follow.\n",
                          "items": {
                            "$ref": "#/components/schemas/Subscription"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "twoLiveSubscriptions": {
                    "summary": "Two live subscriptions, most recently subscribed first, each a bare reference.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "subscription",
                          "id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "created_at": "2026-08-26T12:20:05Z"
                        },
                        {
                          "object": "subscription",
                          "id": "55667788-99aa-4bb2-c3d4-e5f607182930",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "2c8d4e10-9b3a-4f52-8e71-5d0c6b7a8e93"
                          },
                          "created_at": "2025-12-02T07:44:19Z"
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/saved-articles": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        }
      ],
      "get": {
        "operationId": "listSavedArticles",
        "summary": "List the articles this account saved",
        "description": "This account's reading list: the articles this person put aside to come\nback to, most recently saved first. It is private to them, and no\noperation in this API reads somebody else's.\n\nA save is never reported to the newsletter that published the article.\nReads and likes are, as engagement records and as the `article.read` and\n`article.liked` topics; a save has no topic, no tally on\n`Article.stats`, and no engagement event type.\n\n**Needs `account: read`**, which either an API key or an OAuth token can\ncarry.\n\nThe two rules that gate every article this API serves gate this page\ntoo, applied against the person rather than against a newsletter. An\narticle dated in the future is not returned until that moment passes. An\narticle stamped with an audience is returned only while this person is\nentitled to it: the newsletter's owner, one of its admins or editors, or\na subscriber holding one of the article's tags. Leave the segment an\narticle was addressed to and it leaves this page.\n\nSo a page can be shorter than the number of saves the person made, and\nan entry can disappear without the person having removed it. Neither is\nan error and nothing in the response marks it.\n\n`article` is a reference, and `?expand=article` replaces it with an\n`ArticleSummary`: title, preview line, cover and publication date,\nwithout the body.\n\n**Expanding cannot widen the page.** The two rules above are applied\nagain when the article is resolved, so an entry the person may no longer\nread, or one whose article has been deleted, keeps its bare reference.\nTreat a reference that stayed a reference as \"no detail available\",\nnever as an error.\n\n`GET /liked-articles` is the same page for likes and follows every rule\nabove.\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "account:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of saved articles, most recently saved first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "The articles this account put aside to read later, most\nrecently saved first. An entry is present only while\nthis person may still read the article it names, so a\npage can be shorter than the number of saves they\nmade and an entry can disappear without them having\nremoved it. Neither is an error and nothing in the\nresponse marks it. Each `article` is a reference\nunless `?expand=article` asks for the summary, and an\narticle this person may no longer read keeps its\nreference either way.\n",
                          "items": {
                            "$ref": "#/components/schemas/SavedArticle"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "twoSaves": {
                    "summary": "Two articles on the reading list, most recently saved first.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "saved_article",
                          "article": {
                            "object": "article",
                            "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
                          },
                          "created_at": "2026-08-26T10:02:37Z"
                        },
                        {
                          "object": "saved_article",
                          "article": {
                            "object": "article",
                            "id": "5b7a1d90-2c34-4e18-9f6b-8d0a1c2b3e4f"
                          },
                          "created_at": "2026-08-19T18:20:04Z"
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/liked-articles": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        }
      ],
      "get": {
        "operationId": "listLikedArticles",
        "summary": "List the articles this account liked",
        "description": "The articles this person liked, most recently liked first.\n\nThe same page as `GET /saved-articles`: the same gates, the same\n`?expand=article` gated the same way, the same silence when an entry is\ngated away. Read that operation for every rule this one also obeys.\n\n**A like is not a save.** A save is a private list a person keeps; a\nlike is a signal they gave the newsletter, and the newsletter can read\nit. Do not treat the two as interchangeable.\n\n**This is the reader's half of a like, not the newsletter's.** The tally\nis `article.stats.likes`, which any credential reads. Who is behind it\nis readable by the newsletter the like was aimed at, as engagement\nrecords on `GET /newsletters/{newsletter}/events?event_type=like` and as\nthe `article.liked` topic. This operation is scoped to a person instead:\none person's own likes across every newsletter they read.\n\n**Needs `account: read`**, which either an API key or an OAuth token can\ncarry.\n",
        "tags": [
          "Articles"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "account:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of liked articles, most recently liked first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "The articles this account liked, most recently liked\nfirst. Same gates as the saved list: an entry is\npresent only while this person may still read the\narticle, so a page can be shorter than the number of\nlikes they left and an entry can disappear on its\nown. This is one person's own likes across every\nnewsletter they read, never a roster of who liked one\narticle. Each `article` is a reference unless\n`?expand=article` asks for the summary.\n",
                          "items": {
                            "$ref": "#/components/schemas/LikedArticle"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "twoLikes": {
                    "summary": "Two liked articles, most recently liked first, each a bare reference.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "liked_article",
                          "article": {
                            "object": "article",
                            "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
                          },
                          "created_at": "2026-08-26T09:58:12Z"
                        },
                        {
                          "object": "liked_article",
                          "article": {
                            "object": "article",
                            "id": "5b7a1d90-2c34-4e18-9f6b-8d0a1c2b3e4f"
                          },
                          "created_at": "2026-08-19T09:47:51Z"
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/users/{user}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/UserPath"
        }
      ],
      "get": {
        "operationId": "getUser",
        "summary": "Retrieve a user",
        "description": "Read one public profile by `id` or by `username`. This is the whole\npublic shape of a person in Commune.\n",
        "tags": [
          "Users"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The user.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/users/ada \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/insights": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterInsights",
        "summary": "List a newsletter's subscriber insights",
        "description": "Per subscriber engagement scoring. Each row blends what a reader did in\nthe newsletter's community with what its email provider reported about\nthem, carries a fourteen day over fourteen day velocity, and lands on a\nlifecycle status. This is what a CRM or a re engagement automation\nreads.\n\nScores are recomputed by a scheduled pass rather than at read time, so a\nrow is only as fresh as the last pass, and `last_action_at` can be newer\nthan the score that reflects it.\n\n**Only people with a Commune account are scored.** Someone the\nnewsletter knows only as an email address has no row here rather than a\nzero score, so the size of this collection is not the size of the\naudience.\n\n`status` is assigned by rank within the newsletter rather than by an\nabsolute score, so it moves when the people around a reader move even if\nthat reader did nothing.\n\nOrdered by `total_score` descending, so the first page is the\nnewsletter's most engaged readers. Needs `insights: read`, and is one of\nthe reads Commune may put behind a plan: a credential whose newsletter\nis not entitled answers `402`.\n\n**`?expand=subscriber` saves a request per reader.** Each insight's\n`subscriber` is a reference by default, and acting on the list (say,\nemailing the readers who are cooling off) would otherwise mean one\n`GET /subscribers/{subscriber}` per row. Expanded, every row carries\nthe full `Subscriber`, the same object that operation returns,\nincluding `email` and `status`, in this one response. Accepted paths\nare `newsletter` and `subscriber`.\n\nExpanding `subscriber` puts email addresses in the response, so it also\nneeds `audience: read` on this newsletter. A credential that holds\n`insights: read` without it is refused with `403`\n`insufficient_scope`, `param` set to `expand`, and `allowed_values`\nlisting what it may expand instead; it is never handed references in\nplace of the subscribers it asked for. A response that expands\n`subscriber` counts against the `audience` rate limit budget and is\nrecorded in Commune's audit log, like any other read of subscribers.\n",
        "tags": [
          "Engagement"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "insights:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Return only subscribers in this lifecycle status. Repeat the\nparameter to accept several.\n",
            "schema": {
              "$ref": "#/components/schemas/InsightStatus"
            }
          },
          {
            "name": "velocity",
            "in": "query",
            "required": false,
            "description": "Return only subscribers whose engagement is moving this way. Repeat\nthe parameter to accept several.\n",
            "schema": {
              "$ref": "#/components/schemas/InsightVelocity"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of subscriber insights, highest scoring first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "This page of scored readers, highest `total_score`\nfirst. Only people with a Commune account are scored,\nso somebody the newsletter knows only as an address\nhas no entry at all rather than an entry scoring\nzero, and the length of this collection is not the\nsize of the audience. Scores come from a scheduled\npass rather than from read time, so `last_action_at`\ncan be newer than the scores beside it, and `status`\nis assigned by rank within the newsletter, so it\nmoves when the people around a reader move.\n",
                          "items": {
                            "$ref": "#/components/schemas/SubscriberInsight"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "aSuperfanAndADormantReader": {
                    "summary": "The top of the ranking and a reader who has cooled, highest score first.",
                    "value": {
                      "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": "44556677-8899-4aa1-b2c3-d4e5f6071829"
                          },
                          "total_score": 88,
                          "community_score": 88,
                          "esp_score": 0,
                          "t1_score": 0,
                          "t2_score": 12,
                          "velocity": "cooling",
                          "status": "dormant",
                          "share_points": 0,
                          "last_action_at": "2026-07-02T09:12:40Z",
                          "synced_to_esp_at": null
                        }
                      ],
                      "pagination": {
                        "has_more": true,
                        "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
                      }
                    }
                  },
                  "aCoolingReaderWithTheSubscriberExpanded": {
                    "summary": "`?expand=subscriber&status=dormant&velocity=cooling`: the readers to write to, with their addresses, in one request.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "subscriber_insight",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "subscriber": {
                            "object": "subscriber",
                            "id": "44556677-8899-4aa1-b2c3-d4e5f6071829",
                            "newsletter": {
                              "object": "newsletter",
                              "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                            },
                            "user": {
                              "object": "user",
                              "id": "usr_2xR8kQ4mN7pL1vB9"
                            },
                            "email": "dana@example.com",
                            "status": "subscribed",
                            "source": "commune",
                            "tags": [],
                            "created_at": "2026-02-14T10:31:07Z",
                            "synced_at": null
                          },
                          "total_score": 88,
                          "community_score": 88,
                          "esp_score": 0,
                          "t1_score": 0,
                          "t2_score": 12,
                          "velocity": "cooling",
                          "status": "dormant",
                          "share_points": 0,
                          "last_action_at": "2026-07-02T09:12:40Z",
                          "synced_to_esp_at": null
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/insights?status=superfan\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/events": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterEvents",
        "summary": "List a newsletter's engagement events",
        "description": "The raw engagement stream the insight scores are built from, with both\norigins unified into one vocabulary: what a reader did inside the\ncommunity, and what the newsletter's email provider reported about the\nsame person. `source` says which side an event came from.\n\nThese are engagement records, not the events Commune pushes to a\nconsumer. They describe reader behaviour and are read on request. What\nCommune pushes is the `webhooks` block of this document.\n\nOrdered by `id` descending, and the cursor walks the same key, so a\nwarehouse can tail this collection and be sure that nothing inserted\nmid page is skipped.\n\nEvery event is attributed to a Commune account, so a subscriber the\nnewsletter knows only as an email address never appears here even when\nthe provider reported an open for that address.\n\nNeeds `insights: read`. A newsletter whose plan does not include\ninsights answers `402`.\n",
        "tags": [
          "Engagement"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "insights:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "event_type",
            "in": "query",
            "required": false,
            "description": "Return only events of this kind. Repeat the parameter to accept\nseveral.\n",
            "schema": {
              "$ref": "#/components/schemas/EngagementEventType"
            }
          },
          {
            "name": "source",
            "in": "query",
            "required": false,
            "description": "Return only events from this origin. `community` is behaviour\nCommune observed directly, `esp` is behaviour the newsletter's email\nprovider reported.\n",
            "schema": {
              "$ref": "#/components/schemas/EngagementEventSource"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of engagement events, most recent first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "This page of scored actions, ordered by `id`\ndescending, which is the key the cursor walks:\nnothing inserted mid page is skipped, so a warehouse\ncan tail this collection. `source` says which side an\nentry came from, behaviour Commune observed itself or\nbehaviour the newsletter's email provider reported,\nand the provider's half arrives on that provider's\nschedule. Every entry is attributed to a Commune\naccount, so a subscriber known only as an address\nnever appears here even when the provider reported\nsomething for that address.\n",
                          "items": {
                            "$ref": "#/components/schemas/EngagementEvent"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "oneFromEachSide": {
                    "summary": "A community action and a provider reported one, newest first.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "engagement_event",
                          "id": "4815162342",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "subscriber": {
                            "object": "subscriber",
                            "id": "33445566-7788-4990-a1b2-c3d4e5f60718"
                          },
                          "event_type": "like",
                          "source": "community",
                          "points": 5,
                          "metadata": {
                            "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
                          },
                          "created_at": "2026-08-26T21:04:11Z"
                        },
                        {
                          "object": "engagement_event",
                          "id": "4815162299",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "subscriber": {
                            "object": "subscriber",
                            "id": "33445566-7788-4990-a1b2-c3d4e5f60718"
                          },
                          "event_type": "email_open",
                          "source": "esp",
                          "points": 1,
                          "metadata": {
                            "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
                          },
                          "created_at": "2026-08-26T09:41:06Z"
                        }
                      ],
                      "pagination": {
                        "has_more": true,
                        "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/events?source=esp&limit=100\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/stats": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "getNewsletterStats",
        "summary": "Retrieve a newsletter's headline numbers",
        "description": "One snapshot of a newsletter over a window: how the audience moved, what\nwas published, how much the community talked, and how the email\nperformed. It answers the question a dashboard opens with, in one call\nrather than six.\n\nEvery number is scoped to the window. Pick the window with `period`, or\nstate it exactly with `since` and `until`.\n\nRead `audience` and `publishing` carefully before charting them.\nCommune's record of a newsletter's subscribers is complete only for a\n`commune` newsletter. For one connected to an outside\nprovider it is a partial cache of that provider's list, which is why the\nfield is called `known_subscribers` and not `subscriber_count`. Do not\npresent it as the newsletter's audience size, and ask the provider for\nthat number instead. `publishing.sent` counts the articles Commune has a\nrecord of and is never a count of emails delivered, which lives in\n`delivery`.\n\nNeeds `insights: read`. What a key counts depends on whether it acts\nfor the newsletter, which a key does when it holds `write` in any\nfamily. Such a key counts everything, including drafts, scheduled\narticles and conversations visible only to subscribers. A key that\nholds only read permissions is a reader of the published newsletter\nand counts what a reader can see: `publishing.sent` covers published\narticles that are not restricted to a tag, `publishing.scheduled` is\n`null`, `community.threads`, `messages` and `reactions` cover public\nconversations and the replies in them (an article's own discussion is\nvisible to subscribers only, so it is not among them), and\n`community.highlights` covers the articles it can read. `audience` and\n`delivery` are the same for both kinds of key.\n",
        "tags": [
          "Metrics"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "insights:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "$ref": "#/components/parameters/StatsPeriod"
          },
          {
            "$ref": "#/components/parameters/StatsSince"
          },
          {
            "$ref": "#/components/parameters/StatsUntil"
          }
        ],
        "responses": {
          "200": {
            "description": "The newsletter's numbers for the resolved window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NewsletterStats"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/stats?period=90d\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/growth": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "getNewsletterGrowth",
        "summary": "Retrieve a newsletter's acquisition breakdown",
        "description": "Where a newsletter's new subscribers came from over a window: the split\nby the import that brought them in, and how the invite funnel\nperformed.\n\n`by_source` is about imports. It groups on the value an import stamped,\nso migrating a newsletter between providers later never relabels the\nhistory: a value that is already set is never overwritten. For whether\nsomebody subscribed through Commune at all, read `source` on the\nsubscriber, which is a different question with a different answer.\n\nThe counts here are arrivals Commune recorded inside the window and are\nnot the newsletter's audience size. For a newsletter connected to an\noutside provider they also miss anyone who joined at the provider\nbetween two imports.\n",
        "tags": [
          "Metrics"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "insights:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "$ref": "#/components/parameters/StatsPeriod"
          },
          {
            "$ref": "#/components/parameters/StatsSince"
          },
          {
            "$ref": "#/components/parameters/StatsUntil"
          }
        ],
        "responses": {
          "200": {
            "description": "The acquisition breakdown for the resolved window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NewsletterGrowth"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/growth?period=90d\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/timeseries": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "getNewsletterTimeseries",
        "summary": "Retrieve one newsletter metric bucketed over time",
        "description": "One metric, bucketed, for charting. Every other operation in this domain\nanswers with a snapshot, which is the wrong shape for a line, so this\none returns the series behind it.\n\nName the quantity with `metric` and the bucket width with `interval`.\nBuckets are contiguous, and a bucket in which nothing happened comes\nback with a `value` of `0` rather than being omitted, so a client can\nplot the array without filling the gaps itself.\n\n`subscribers` counts subscriptions Commune recorded in each bucket, not\nthe running audience size, and it inherits the partial cache caveat that\napplies wherever Commune counts the subscribers of a newsletter\nconnected to an outside provider. `sends` counts the dispatches Commune\nperformed and is never a count of every email the newsletter has sent,\nbecause an imported article was mailed by the provider before Commune saw\nit.\n\nNeeds `insights: read`. `threads` and `messages` depend on whether the\nkey acts for the newsletter, which a key does when it holds `write` in\nany family. Such a key counts every conversation. A key that holds only\nread permissions counts public conversations and the replies in them,\nwhich leaves out an article's own discussion, visible to subscribers\nonly. The other four metrics are the same for both kinds of key.\n",
        "tags": [
          "Metrics"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "insights:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "$ref": "#/components/parameters/StatsSince"
          },
          {
            "$ref": "#/components/parameters/StatsUntil"
          },
          {
            "name": "metric",
            "in": "query",
            "required": true,
            "description": "Which quantity to bucket. Exactly one per request.",
            "schema": {
              "$ref": "#/components/schemas/TimeseriesMetric"
            }
          },
          {
            "name": "interval",
            "in": "query",
            "required": false,
            "description": "How wide each bucket is. Defaults to `day`. A week starts on Monday\nand a month on the first, both in UTC.\n",
            "schema": {
              "$ref": "#/components/schemas/TimeseriesInterval"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The requested metric, bucketed over the resolved window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Timeseries"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/timeseries?metric=subscribers&interval=week\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/articles/{article}/stats": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/ArticlePath"
        }
      ],
      "get": {
        "operationId": "getArticleStats",
        "summary": "Retrieve one article's performance",
        "description": "How one article did on both sides at once: what the email did, and what\nthe community did with it afterwards. No email provider can return the\nright hand column, because the reading, highlighting and arguing\nhappened on Commune.\n\n`email` is present only for an article Commune sent itself. For an article\nimported from an outside provider it is `null`, because that provider\nmailed it and never handed Commune the per recipient outcome. It is\n`null` for an article that has not been sent yet as well.\n\n`community` is computed at read time and keeps counting long after the\nsend, so two reads a week apart legitimately disagree.\n\nNeeds `insights: read`. The public tallies on an article, `likes`,\n`comments` and `highlights`, live on the article itself and need only\n`content: read`.\n\nWhich articles a key can measure depends on whether it acts for the\nnewsletter, which a key does when it holds `write` in any family. Such a\nkey can measure any of the newsletter's articles, including a draft or\none scheduled for later. A key that holds only read permissions can\nmeasure exactly the articles `getArticle` would return to it: sent,\nalready published and not restricted to a tag. Any other article\nanswers `404`, the same answer as an id that does not exist, so this\noperation never confirms that an unpublished article exists. For an\narticle both kinds of key can read, the numbers are the same.\n",
        "tags": [
          "Metrics"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "insights:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The article's email and community performance.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ArticlePerformance"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/articles/k7Rm2xQp/stats \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/destinations": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterDestinations",
        "summary": "List where a newsletter's events go",
        "description": "Every destination this newsletter's published events are delivered to,\nnewest first. Needs `webhooks: read`.\n\nA destination is not necessarily an HTTPS endpoint. It can also be a\nqueue, a stream or an object store, for a consumer that would rather not\nrun a web server. `type` says which kind this one is.\n\nRead only. Destinations are added, edited and disabled in the delivery\nportal, and `POST /newsletters/{newsletter}/portal-session` mints the\nlink to it.\n\nA newsletter that has never opened that portal has no destinations and\nanswers with an empty page, which is different from an error: an event\npublished by a newsletter with no destinations is accepted, recorded and\ndelivered nowhere.\n\nNothing a destination authenticates with is returned, including the\nsecret its deliveries are signed with and any request header the creator\nconfigured on it. `target` is a display summary of where it points, the\nhost of an endpoint or the name of a queue, and it is the identifying\ndetail this operation returns in place of the full configuration. The\nportal is where the rest of it can be read by the person who set it up.\n",
        "tags": [
          "Event delivery"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "webhooks:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of destinations, most recently added first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "Every place this newsletter's published events are\ndelivered to, most recently added first. An empty\npage is the ordinary state for a newsletter that has\nnever opened the delivery portal and is not an error:\nan event published by a newsletter with no\ndestinations is accepted, recorded, and delivered\nnowhere. Nothing a destination authenticates with is\non an entry, and `target` is the display summary\nreturned in place of the full configuration.\n",
                          "items": {
                            "$ref": "#/components/schemas/Destination"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "anEndpointAndAQueue": {
                    "summary": "One HTTPS endpoint and one queue, newest first, with a disabled destination still listed.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "destination",
                          "id": "des_7Jq2Wm4pXc",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "type": "aws_sqs",
                          "target": "commune-events",
                          "topics": [
                            "subscriber.created",
                            "subscriber.unsubscribed"
                          ],
                          "enabled": false,
                          "disabled_at": "2026-09-01T09:14:00Z",
                          "created_at": "2026-08-30T13:22:41Z",
                          "updated_at": "2026-09-01T09:14:00Z"
                        },
                        {
                          "object": "destination",
                          "id": "des_4Nb8Fy1kLd",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "type": "webhook",
                          "target": "hooks.example.org",
                          "topics": [
                            "article.published",
                            "send.completed"
                          ],
                          "enabled": true,
                          "disabled_at": null,
                          "created_at": "2026-08-27T10:05:19Z",
                          "updated_at": null
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/newsletters/{newsletter}/delivery-attempts": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterDeliveryAttempts",
        "summary": "List what was delivered where, and how it went",
        "description": "Every time Commune handed one of this newsletter's events to one of its\ndestinations, newest first: which event, which destination, what came\nback, and whether it was a first try or a retry. Needs `sending: read`.\n\nThis is the answer to \"my endpoint never received that event\". Each row\nnames an `event_id`, which is the same string the consumer sees in the\n`Commune-Event-Id` header and in the envelope's `id`, so a line in your\nown logs and a row here can be matched up. `?event_id=` goes the other\nway: give it an id and get every attempt at delivering that one event.\n\nAn event is delivered once per destination, so one event with three\ndestinations produces at least three rows here. `?destination_id=`\nnarrows to one of them, and `?status=failed` is the usual first read.\n\n**A retry is a new row, not an edit.** `attempt` is 1 on the first try\nand one higher on each retry, and the delivery service retries a failed\ndelivery on its own with backoff, so a row with `status: failed` is not\nyet a lost event. `manual` says whether somebody asked for the attempt\nrather than it being automatic.\n\nAttempts are recorded shortly after delivery rather than instantly, so\nan attempt made a moment ago may not be on this page yet. Read again\nrather than concluding nothing was tried. The log is a recent record\nrather than an archive, so keep anything you need to hold on to.\n\nTwo things are never returned: whatever a destination authenticates\nwith, and the response body your endpoint answered with, since a\nrefusing endpoint routinely echoes the request back inside it, headers\nincluded. `response_status` stands in for the body, and the delivery\nportal has the rest.\n",
        "tags": [
          "Event delivery"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Return only attempts that ended this way. Omit for both.\n",
            "schema": {
              "$ref": "#/components/schemas/DeliveryAttemptStatus"
            }
          },
          {
            "name": "event_id",
            "in": "query",
            "required": false,
            "description": "Return only attempts at delivering this event, by the `id` from the\nenvelope and from the `Commune-Event-Id` header.\n",
            "schema": {
              "type": "string",
              "maxLength": 128
            }
          },
          {
            "name": "destination_id",
            "in": "query",
            "required": false,
            "description": "Return only attempts at this destination, by the `id` from\n`GET /newsletters/{newsletter}/destinations`.\n",
            "schema": {
              "type": "string",
              "maxLength": 128
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of delivery attempts, most recent first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "This page of handovers, most recent first, one entry\nper attempt at one destination. A retry is a new\nentry with a higher `attempt` rather than an edit to\nthe one before it, so a single event delivered to two\ndestinations and retried once at one of them is three\nentries here. `status: failed` is not yet a lost\nevent: the delivery service retries on its own with\nbackoff. Attempts are recorded shortly after delivery\nrather than instantly, so one made a moment ago may\nnot be here yet. Neither the destination's\ncredentials nor the body it answered with is on an\nentry.\n",
                          "items": {
                            "$ref": "#/components/schemas/DeliveryAttempt"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "aFailureAndItsRetry": {
                    "summary": "The same event failing and then succeeding on the next attempt, newest first.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "delivery_attempt",
                          "id": "att_5Kd9Rb2mQx",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "destination": {
                            "object": "destination",
                            "id": "des_4Nb8Fy1kLd"
                          },
                          "destination_type": "webhook",
                          "event_id": "018f2a8b-6c4b-7d2e-9f11-6a1c3d5e7b90",
                          "event_type": "article.published",
                          "status": "succeeded",
                          "response_status": 200,
                          "failure": null,
                          "attempt": 2,
                          "manual": false,
                          "created_at": "2026-08-26T09:33:04Z"
                        },
                        {
                          "object": "delivery_attempt",
                          "id": "att_2Hf6Vp8sZn",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "destination": {
                            "object": "destination",
                            "id": "des_4Nb8Fy1kLd"
                          },
                          "destination_type": "webhook",
                          "event_id": "018f2a8b-6c4b-7d2e-9f11-6a1c3d5e7b90",
                          "event_type": "article.published",
                          "status": "failed",
                          "response_status": null,
                          "failure": "timeout",
                          "attempt": 1,
                          "manual": false,
                          "created_at": "2026-08-26T09:32:19Z"
                        }
                      ],
                      "pagination": {
                        "has_more": true,
                        "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/delivery-attempts/{attempt}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/DeliveryAttemptPath"
        }
      ],
      "get": {
        "operationId": "getDeliveryAttempt",
        "summary": "Retrieve one delivery attempt",
        "description": "One attempt from the delivery log, by its `id`. Needs `sending: read`.\nThe same shape a page of them carries, and what\n`POST /delivery-attempts/{attempt}/replay` acts on.\n\nAn attempt belonging to another newsletter answers `404`, the same as\none that does not exist.\n\nThe delivery log is kept per newsletter and an attempt is not a Commune\nobject, so there is nothing in the path to work out which newsletter to\nlook in: name it with `?newsletter=`. Leave it out if your credential\nreaches exactly one newsletter.\n\n**The body your endpoint answered with is not returned**, since a\nrefusing server routinely echoes the request back inside it, credentials\nincluded. What came back is `response_status`, or `failure` when nothing\nanswered at all; the full body is in the delivery portal.\n",
        "tags": [
          "Event delivery"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/NewsletterChoice"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The delivery attempt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeliveryAttempt"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/delivery-attempts/{attempt}/replay": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        },
        {
          "$ref": "#/components/parameters/DeliveryAttemptPath"
        }
      ],
      "post": {
        "operationId": "replayDeliveryAttempt",
        "summary": "Send an event to a destination again",
        "description": "Asks for the event behind this attempt to be delivered to the same\ndestination again. Needs `sending: write`. The same action as the retry\nbutton beside that attempt in the delivery portal.\n\n**It re-delivers the event, it does not resend the attempt.** What comes\nback is a *new* attempt against the same event and the same destination,\nnumbered one higher and marked `manual: true`. The attempt this was\ncalled on is unchanged.\n\nReach for it when a delivery failed for a reason you have since fixed\nand the delivery service has stopped retrying on its own. It is not the\nway to catch up after an outage: those retries happen without being\nasked, and replaying an event a consumer already processed is a\nduplicate they have to handle.\n\nA `202` means the delivery service has the request. The delivery happens\nafter the response, and the attempt it produces appears in the log\nshortly afterwards rather than immediately.\n\nA destination that has been switched off answers `422`, since a disabled\ndestination is skipped rather than queued. Switch it back on in the\nportal first.\n\nTakes no body: which event and which destination are both properties of\nthe attempt. It does take `?newsletter=`, for the reason\n`GET /delivery-attempts/{attempt}` does. Leave it out if your credential\nreaches exactly one newsletter.\n",
        "tags": [
          "Event delivery"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/NewsletterChoice"
          }
        ],
        "responses": {
          "202": {
            "description": "The delivery service has the request. The delivery has not happened\nyet, so no attempt is returned; find it afterwards in the delivery\nlog, filtered to the same event.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeliveryReplay"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/newsletters/{newsletter}/portal-session": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "post": {
        "operationId": "createPortalSession",
        "summary": "Open the event delivery portal",
        "description": "Mints a link into the delivery portal for this newsletter, where a\ncreator adds a destination, disables one, rotates the secret its\ndeliveries are signed with, and reads the response body an endpoint\nanswered a failed delivery with. Needs `webhooks: write`.\n\n**This is the only way to create or edit a destination.** There is no\noperation here that does it. The attempt log is readable through\n`GET /newsletters/{newsletter}/delivery-attempts`; what the portal has\nthat this API does not is the response body an endpoint returned, which\nCommune withholds for the same reason it withholds a destination's\nconfigured request headers.\n\n**The returned `url` is a credential.** It carries a bearer token in its\nquery string, scoped to this newsletter, and anyone who opens it can\nchange where this newsletter's events go. Redirect the person who asked\nfor it and let the link be spent. Do not store it, log it, put it in a\nshared document or mail it; minting a new one is cheap.\n\nEach call returns a different link, and nothing here can be fetched\nagain afterwards. Takes no body and no parameters.\n",
        "tags": [
          "Event delivery"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "webhooks:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "A link into the portal, valid for a short time.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalSession"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/newsletters/{newsletter}/community": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterCommunity",
        "summary": "List a newsletter's community",
        "description": "The people who joined this newsletter's community on Commune, most\nrecently joined first. Needs `audience: read`. Every field on a row is a\nfield the same person's own public profile carries, plus the day they\njoined.\n\n**A roster of people, not a census of the audience.** A newsletter that\nbrought its list over from another provider holds subscriptions that are\nan address and nothing else, and none of them is here: there is no\nperson to name. So the length of this collection is a floor under the\ncommunity and is never the newsletter's subscriber count. The list to\nreconcile against a provider is\n`GET /newsletters/{newsletter}/subscribers`, which returns addresses.\n\nOnly people who are currently subscribed are returned. There is no\nparameter for asking about anybody else.\n",
        "tags": [
          "Subscribers"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of community members, most recently joined first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "The people who joined this newsletter's community on\nCommune, most recently joined first. Only currently\nsubscribed people who have a Commune account are\nhere: a subscription that is an address and nothing\nelse has no person to name and is left out, so the\nlength of this collection is a floor under the\ncommunity and never the newsletter's subscriber\ncount. Somebody who left is absent too, and there is\nno parameter for asking about them.\n",
                          "items": {
                            "$ref": "#/components/schemas/CommunityMember"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "twoRecentJoiners": {
                    "summary": "The two most recent people to join, each a public profile and the day they joined.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "community_member",
                          "id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "user": {
                            "object": "user",
                            "id": "usr_2Nf8Kq1pWc"
                          },
                          "created_at": "2026-08-26T12:20:05Z"
                        },
                        {
                          "object": "community_member",
                          "id": "66778899-aabb-4cc3-d4e5-f60718293041",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "user": {
                            "object": "user",
                            "id": "usr_9Lp3Zr7tYb"
                          },
                          "created_at": "2026-08-22T17:36:52Z"
                        }
                      ],
                      "pagination": {
                        "has_more": true,
                        "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/community?expand=user&limit=50\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/sends": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterSends",
        "summary": "List a newsletter's sends",
        "description": "Every dispatch this newsletter has made, most recent first, with the\nnumbers each one finished on. Needs `sending: read`.\n\nA send is one run rather than one article. An article that went out once\nhas one; an article that failed and was sent again has two, and\n`?article=` narrows the collection to one article's runs. An article\nimported from an outside provider has none, because that provider\nmailed it, and neither has an article whose audience worked out to\nnobody.\n\nThe three counts are the three a `send.completed` event publishes,\nunder the same names, so a consumer holding a stored event and a caller\nreading this back are holding one set of numbers rather than two\nspellings of them. `Send` says what each one counts and what none of\nthem counts.\n\nExpanding `article` inlines an `Article`, which is `content` rather than\n`sending`, so it also needs `content: read` on this newsletter. A\ncredential that holds `sending: read` without it is refused with `403`\n`insufficient_scope`, `param` set to `expand`, and `allowed_values`\nlisting what it may expand instead (`newsletter`). Unexpanded, `article`\nis a reference and needs nothing more. Accepted paths are `article` and\n`newsletter`.\n",
        "tags": [
          "Sends"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "article",
            "in": "query",
            "required": false,
            "description": "Only the runs that dispatched this article, by its `id`. The\n`short_id` is not accepted here: a filter takes the canonical\nidentifier, and `data[].article` returns one.\n",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of sends, most recent first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "This page of dispatch runs, most recent first by\n`created_at`. One entry is one run and not one article:\nan article sent twice has two, an article imported from\nan outside provider has none because that provider\nmailed it, and an article whose audience worked out to\nnobody has none either. A run that has not finished\ncarries a `null` `completed_at` with `sent_count` and\n`failed_count` both zero, which is not a progress\nreport: the three counts are settled once, at the\nend, and do not move afterwards even when the\nrecipients a run missed are later reached.\n",
                          "items": {
                            "$ref": "#/components/schemas/Send"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "oneFinishedOneInFlight": {
                    "summary": "A run still handing recipients over, and the finished run before it.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "send",
                          "id": "8b7c6d5e-4f3a-4b2c-1d0e-9f8a7b6c5d4e",
                          "article": {
                            "object": "article",
                            "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
                          },
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "started_at": "2026-09-08T09:30:02Z",
                          "completed_at": null,
                          "recipient_count": 12480,
                          "sent_count": 0,
                          "failed_count": 0,
                          "created_at": "2026-09-08T09:30:00Z"
                        },
                        {
                          "object": "send",
                          "id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                          "article": {
                            "object": "article",
                            "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
                          },
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "started_at": "2026-08-26T09:30:04Z",
                          "completed_at": "2026-08-26T09:32:11Z",
                          "recipient_count": 12310,
                          "sent_count": 12287,
                          "failed_count": 23,
                          "created_at": "2026-08-26T09:30:00Z"
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl \"https://api.usecommune.com/newsletters/the-weekly/sends?article=4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/sends/{send}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/SendPath"
        }
      ],
      "get": {
        "operationId": "getSend",
        "summary": "Retrieve a send",
        "description": "One dispatch, by the identifier a `send.completed` or `send.failed`\nevent carried, or the one a retry reported back. Needs `sending: read`.\n\nThis is the operation those events point at. An identifier arrives on a\nwebhook and this reads the run behind it, so a consumer does not have\nto have kept the payload to be able to ask again.\n\nExpanding `article` inlines an `Article`, which is `content` rather than\n`sending`, so it also needs `content: read` on this newsletter. A\ncredential that holds `sending: read` without it is refused with `403`\n`insufficient_scope`, `param` set to `expand`, and `allowed_values`\nlisting what it may expand instead (`newsletter`). Unexpanded, `article`\nis a reference and needs nothing more. Accepted paths are `article` and\n`newsletter`.\n",
        "tags": [
          "Sends"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The send.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Send"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/sends/9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/entitlements": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "getNewsletterEntitlements",
        "summary": "What this credential may call",
        "description": "What this credential is allowed to do with this newsletter, and what it\nwould be told if it tried something it is not. Needs `settings: read`.\n\nTwo things in this API can be put behind a plan, and both answer `402`\nwhen they are: **writing**, every operation that changes something, and\n**insights**, the engagement and metrics operations, whose numbers are\ncomputed rather than looked up. Every other read is free on every plan.\n\nRead this at the start of a run rather than discovering a `402` in the\nmiddle of one. Each entry in `api_access` carries the plan list the\nrefusal would put in `allowed_values` and the sentence it would put in\n`message`.\n\n**`granted` is the field to branch on**: `true` when the call would be\nallowed at this moment. `included` is the longer view, whether the\nnewsletter's plan carries the feature at all, and the two differ only\nwhile Commune is not charging for it. Read `included` to warn a creator\nbefore a bill starts; read `granted` to decide whether to make the next\ncall.\n\nNever itself refused for payment, so it declares no `402`.\n",
        "tags": [
          "Platform"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "What this newsletter's plan includes, and what a credential on it\nmay call.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Entitlements"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://api.usecommune.com/newsletters/the-weekly/entitlements \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"
          }
        ]
      }
    },
    "/newsletters/{newsletter}/api-keys": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterApiKeys",
        "summary": "List this newsletter's API keys",
        "description": "Every API key that can reach this newsletter, newest first, revoked\nones included. Needs `settings: write`, not `read`.\n\n\"Can reach\" rather than \"was issued for\": a key can be granted every\nnewsletter its owner runs rather than a named list, and such a key\nappears here too.\n\n**No response from this API ever contains a key's secret.** A secret\nexists in plaintext for one moment, in the reply to the person who\nminted it in Commune's settings, and Commune keeps only a digest. An\nentry carries `key_prefix` instead, the leading fifteen characters,\nwhich tells keys apart and cannot be used as one.\n\n`self` marks the entry this request was made with, which is otherwise\nimpossible to work out: a caller holds a secret and the rows carry ids.\nIt is `false` on every row when the request was made with an OAuth\ntoken, since an OAuth token is not an API key and is not listed here.\n\nRevoked keys stay in the list, so \"when was that turned off, and what\nwas it called\" stays answerable. Read `live` to tell the keys that still\nwork from the ones that do not.\n\nThere is no filter. A newsletter holds at most twenty live keys, so the\ncollection fits in a page or two.\n",
        "tags": [
          "Platform"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of keys, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "Every credential that can reach this newsletter,\nnewest first, revoked ones included, whether it names\nthis newsletter or was granted every newsletter its\nowner runs, and not one of them\ncarrying a secret: `key_prefix` is the leading\nfifteen characters and is all that survives of one.\n`self` marks the single entry this request was made\nwith, and is `false` on every entry for a request\nmade with an OAuth access token. There is no filter\non this collection, which fits in a page or two; read\n`live` to tell the keys that still work from the ones\nthat do not.\n",
                          "items": {
                            "$ref": "#/components/schemas/ApiKey"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "theCallersOwnKeyAndARevokedOne": {
                    "summary": "The key this request was made with, and an older revoked one still on the list.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "api_key",
                          "id": "9f0a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "name": "Warehouse sync",
                          "key_prefix": "cmn_sk_7Qd2xLpA",
                          "permissions": {
                            "content": "write",
                            "audience": "write",
                            "sending": "write",
                            "insights": "write",
                            "settings": "write",
                            "webhooks": "write"
                          },
                          "pinned_version": "2026-08-26",
                          "live": true,
                          "revoked": false,
                          "revoked_at": null,
                          "expires_at": null,
                          "last_used_at": "2026-09-08T09:41:22Z",
                          "self": true,
                          "created_by": {
                            "object": "user",
                            "id": "usr_2Nf8Kq1pWc"
                          },
                          "created_at": "2026-08-27T11:02:44Z"
                        },
                        {
                          "object": "api_key",
                          "id": "8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "name": "Old website widget",
                          "key_prefix": "cmn_sk_3Vn6yTgH",
                          "permissions": {
                            "content": "read",
                            "audience": "none",
                            "sending": "none",
                            "insights": "none",
                            "settings": "none",
                            "webhooks": "none"
                          },
                          "pinned_version": "2026-08-26",
                          "live": false,
                          "revoked": true,
                          "revoked_at": "2026-09-01T08:12:00Z",
                          "expires_at": null,
                          "last_used_at": "2026-08-31T22:47:10Z",
                          "self": false,
                          "created_by": {
                            "object": "user",
                            "id": "usr_5Qw8Hn2vFd"
                          },
                          "created_at": "2026-08-26T17:19:30Z"
                        }
                      ],
                      "pagination": {
                        "has_more": false,
                        "next_cursor": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api-keys/{key}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/ApiKeyPath"
        }
      ],
      "get": {
        "operationId": "getApiKey",
        "summary": "Retrieve an API key",
        "description": "One of this newsletter's API keys, by `id`, revoked or not. Needs\n`settings: write`. Carries no secret: Commune stores none to return.\n\nA key belonging to another newsletter answers `404`, whichever\nnewsletter the caller names.\n",
        "tags": [
          "Platform"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiKey"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "delete": {
        "operationId": "revokeApiKey",
        "summary": "Revoke an API key",
        "description": "Stops this credential working. The next request made with it is refused\nthe same way a string that was never issued is refused.\n\n**The row survives.** `DELETE` stamps the key as revoked rather than\nremoving it, so it stays in the list with its `revoked_at`. Nothing in\nthis API removes a key.\n\n**Revocation only goes one way.** No operation here revives a revoked\nkey, and none mints a new one, so through this API the set of working\ncredentials on a newsletter can only get smaller. Mint a replacement in\nCommune's settings.\n\n**A key may revoke itself**, which is what an integration that knows it\nhas leaked, or a job finished with its credential, should do. `self` on\nthe list operation says which key that is.\n\n**A key may also revoke its siblings**: the other credentials belonging\nto the same person.\n\n**A credential belonging to somebody else answers `403`**, even when it\nreaches the same newsletter and `GET /api-keys/{key}` returns it.\nCutting a credential off one newsletter rather than off everything is\ndone in Commune's settings instead.\n\nRevoking a key that is already revoked succeeds and returns it\nunchanged, with its original `revoked_at`, so a retry after a dropped\nresponse is not a failure.\n\nNever refused for payment, so no `402`. Publishes no event: the\ncredential that was revoked learns it on its next request.\n",
        "tags": [
          "Platform"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The key, as it now stands. `revoked` is true, `live` is false, and\n`revoked_at` is when it was first turned off.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiKey"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    }
  },
  "webhooks": {
    "article.liked": {
      "post": {
        "operationId": "onArticleLiked",
        "summary": "Article like added or removed",
        "description": "A reader liked an article, or took the like back. `data.direction` says\nwhich: `added` when the like was given, `removed` when it was withdrawn.\n\nOne topic carries both directions, so subscribing once is enough to\nmirror the whole association. A consumer that heard a named person liked\nan article and never heard them take it back would keep acting on a\nclaim the person withdrew.\n\n**`data.reader` names the person.** The same actions are readable as\nengagement records at\n`GET /newsletters/{newsletter}/events?event_type=like`. A reader with no\nCommune account cannot like an article, so unlike `article.read` this\nfield is null only for a deleted account.\n\n`data.like_count` is the whole tally after the change, counted in the\nsame transaction that made it, and is the number\n`GET /articles/{article}` reports on `stats.likes`. It is on the removal\nas well as the addition, so a consumer that stores it never has to add\nanything up and a consumer that missed a message is corrected by the\nnext one.\n\n**Fires on a real row change only.** Liking an article this person\nalready liked is silent, and so is unliking one they had not liked.\nLiking, unliking and liking again fires three times.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ArticleLikedEvent"
              },
              "examples": {
                "likeAdded": {
                  "summary": "The eighteenth like on an article.",
                  "value": {
                    "id": "018f2a93-1010-7000-8000-000000000041",
                    "type": "article.liked",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T20:02:14Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "title": "What newsletters get wrong about community",
                      "url": "https://example.com/p/what-newsletters-get-wrong-about-community",
                      "reader": {
                        "user_id": "9f8e7d6c-5b4a-4392-8180-7f6e5d4c3b2a",
                        "username": "mara",
                        "display_name": "Mara Iversen",
                        "avatar_url": "https://example.com/avatars/mara.png"
                      },
                      "direction": "added",
                      "like_count": 18,
                      "changed_at": "2026-08-26T20:02:14Z"
                    }
                  }
                },
                "likeRemoved": {
                  "summary": "The same reader takes it back, and the tally says so. A consumer that stored the first message has to act on this one.",
                  "value": {
                    "id": "018f2a93-2020-7000-8000-000000000042",
                    "type": "article.liked",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T20:41:55Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "title": "What newsletters get wrong about community",
                      "url": "https://example.com/p/what-newsletters-get-wrong-about-community",
                      "reader": {
                        "user_id": "9f8e7d6c-5b4a-4392-8180-7f6e5d4c3b2a",
                        "username": "mara",
                        "display_name": "Mara Iversen",
                        "avatar_url": "https://example.com/avatars/mara.png"
                      },
                      "direction": "removed",
                      "like_count": 17,
                      "changed_at": "2026-08-26T20:41:55Z"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "article.published": {
      "post": {
        "operationId": "onArticlePublished",
        "summary": "Article published",
        "description": "An article became publicly readable on Commune. Two paths reach this\nstate and both publish here, distinguished by `data.source`.\n\n`commune_send`: an article written in Commune finished sending. Its\nstatus moved from `sending` to `sent` and `posted_at` was stamped, which is what\nsurfaces it in the feed.\n\n`import`: a post arrived from a connected ESP or an RSS feed. Feeds are\npolled on a schedule, so this fires without a creator having done\nanything at that moment.\n\nAn article with a future `posted_at` is scheduled, not published, and\ndoes not fire this topic until it is actually live.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ArticlePublishedEvent"
              },
              "examples": {
                "communeArticleSent": {
                  "summary": "An article written in Commune finished sending and went live.",
                  "value": {
                    "id": "018f2a8b-6c4b-7d2e-9f11-6a1c3d5e7b90",
                    "type": "article.published",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T09:32:11Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "title": "What newsletters get wrong about community",
                      "slug": "what-newsletters-get-wrong-about-community",
                      "url": "https://example.com/p/what-newsletters-get-wrong-about-community",
                      "published_at": "2026-08-26T09:32:11Z",
                      "source": "commune_send",
                      "audience_scoped": false
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "article.read": {
      "post": {
        "operationId": "onArticleRead",
        "summary": "Article read",
        "description": "A reader stayed with an article long enough to have read it. Commune\nrecords this when someone has had the article open for ten seconds, or has\nscrolled through it, whichever comes first.\n\n**Fires once per reader per article, ever.** It reports the one moment\nthe pair crosses from unread to read, so a reader who comes back a year\nlater produces nothing. It is a first-time signal, not a visit counter,\nand cannot be summed into one.\n\n**`data.reader` names the person.** The same actions are readable as\nengagement records at\n`GET /newsletters/{newsletter}/events?event_type=view`.\n\n**Three things this topic does not report, each of which will make a\ncount built from it wrong.**\n\n* **A reader who is not signed in produces nothing**, so counting these\n  messages counts signed-in readers and nothing else. Real readership is\n  higher by however much logged-out traffic the newsletter gets, and\n  Commune records nothing durable for an anonymous read, so there is no\n  figure to correct the total by afterwards. On a newsletter with a\n  public archive that can be most of the readership. Treat any number\n  derived from this topic as a floor, label it as signed-in readers, and\n  do not call it an open rate or a view count.\n* **Marking articles read in bulk produces nothing.** A reader clearing\n  a backlog with \"mark all as read\" is declaring they are not going to\n  read those articles. Only reading reaches this topic.\n* **A reader who opened an article and left produces nothing.** That is\n  a different fact, it has no topic, and its absence is why this is not\n  an open rate.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ArticleReadEvent"
              },
              "examples": {
                "read": {
                  "summary": "A signed-in reader finishes an article, once and only once.",
                  "value": {
                    "id": "018f2a93-5050-7000-8000-000000000045",
                    "type": "article.read",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T19:58:31Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "title": "What newsletters get wrong about community",
                      "url": "https://example.com/p/what-newsletters-get-wrong-about-community",
                      "reader": {
                        "user_id": "9f8e7d6c-5b4a-4392-8180-7f6e5d4c3b2a",
                        "username": "mara",
                        "display_name": "Mara Iversen",
                        "avatar_url": "https://example.com/avatars/mara.png"
                      },
                      "read_at": "2026-08-26T19:58:31Z"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "article.scheduled": {
      "post": {
        "operationId": "onArticleScheduled",
        "summary": "Article scheduled",
        "description": "An article written in Commune was queued for a future send. Its status\nbecame `scheduled` and its `scheduled_for` is in the future.\n\n**Cancelling a schedule has no topic.** A consumer that needs to know a\nschedule went away should reconcile against `GET /articles/{article}`.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ArticleScheduledEvent"
              },
              "examples": {
                "scheduledForTomorrow": {
                  "value": {
                    "id": "018f2a90-1111-7000-8000-000000000001",
                    "type": "article.scheduled",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T10:04:00Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "title": "The week we stopped chasing opens",
                      "scheduled_for": "2026-08-27T08:00:00Z"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "billing.subscription.updated": {
      "post": {
        "operationId": "onBillingSubscriptionUpdated",
        "summary": "Billing subscription state changed",
        "description": "The newsletter's own Commune subscription moved between billing states:\ntrialing, active, past_due, canceled or trial_expired.\n\nTwo things move it. Activity at the payment provider: a checkout\ncompleting, the subscription being created, updated or deleted, an\ninvoice being paid or failing. And a daily pass that expires trials\nwhich have run out, which is why a `trial_expired` event can arrive with\nno creator action behind it.\n\nNamed `billing.subscription.*` because in Commune a \"subscriber\" is a\nreader of a newsletter. This topic is about the creator paying Commune.\n\n**A state here gates publishing.** Past due beyond the grace window,\ncanceled, and an expired trial each refuse a send with `402`. A\nnewsletter that is trialing or past due is also capped on how many\nemails it may send in a day, and exceeding that refuses with `429`\nrather than `402`, so a consumer watching for payment problems should\ntreat both codes as billing refusals.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BillingSubscriptionUpdatedEvent"
              },
              "examples": {
                "trialConvertedToActive": {
                  "value": {
                    "id": "018f2a92-1111-7000-8000-000000000011",
                    "type": "billing.subscription.updated",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T17:05:00Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "subscription_id": "f5061728-394a-4b5c-96d7-e8f90a1b2c3d",
                      "plan": "creator",
                      "status": "active",
                      "previous_status": "trialing",
                      "trial_ends_at": "2026-08-26T00:00:00Z",
                      "current_period_start": "2026-08-26T17:05:00Z",
                      "current_period_end": "2026-09-26T17:05:00Z",
                      "cancel_at_period_end": false
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "delivery.bounced": {
      "post": {
        "operationId": "onDeliveryBounced",
        "summary": "Delivery bounced",
        "description": "The message could not be delivered, as reported by the sending\nprovider.\n\nThis has a side effect on the subscriber: they are moved to `bounced`\nso later sends skip them, which also emits `subscriber.unsubscribed`\nwith `reason: bounced`. Expect both events for one bounce.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DeliveryBouncedEvent"
              },
              "examples": {
                "bounced": {
                  "summary": "Arrives together with subscriber.unsubscribed carrying reason bounced.",
                  "value": {
                    "id": "018f2a90-7777-7000-8000-000000000007",
                    "type": "delivery.bounced",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T09:34:02Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "delivery_id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "email": "gone@example.com",
                      "provider_message_id": "4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4",
                      "status": "bounced",
                      "reason": "The recipient address does not exist."
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "delivery.clicked": {
      "post": {
        "operationId": "onDeliveryClicked",
        "summary": "Delivery link clicked",
        "description": "The recipient clicked a tracked link, as reported by the sending\nprovider.\n\n**Which link was clicked is not in the payload.** Commune records only\nthat a click happened and when the first one did, so there is no\nlink-level detail to serve.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DeliveryClickedEvent"
              },
              "examples": {
                "clicked": {
                  "value": {
                    "id": "018f2a90-6666-7000-8000-000000000006",
                    "type": "delivery.clicked",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T10:12:30Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "delivery_id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "email": "reader@example.com",
                      "provider_message_id": "4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4",
                      "status": "clicked"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "delivery.complained": {
      "post": {
        "operationId": "onDeliveryComplained",
        "summary": "Delivery marked as spam",
        "description": "The recipient reported the message as spam, as reported by the sending\nprovider.\n\nTreated as more severe than a bounce and never overridden: the\nsubscriber is moved to `complained` and must not be mailed again,\nwhich also emits `subscriber.unsubscribed` with `reason: complained`.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DeliveryComplainedEvent"
              },
              "examples": {
                "complained": {
                  "value": {
                    "id": "018f2a90-8888-7000-8000-000000000008",
                    "type": "delivery.complained",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T11:02:19Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "delivery_id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "email": "annoyed@example.com",
                      "provider_message_id": "4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4",
                      "status": "complained",
                      "reason": "Spam complaint"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "delivery.delivered": {
      "post": {
        "operationId": "onDeliveryDelivered",
        "summary": "Delivery accepted by the recipient server",
        "description": "The sending provider confirmed the message reached the recipient's mail\nserver.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DeliveryDeliveredEvent"
              },
              "examples": {
                "delivered": {
                  "value": {
                    "id": "018f2a90-4444-7000-8000-000000000004",
                    "type": "delivery.delivered",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T09:32:48Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "delivery_id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "email": "reader@example.com",
                      "provider_message_id": "4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4",
                      "status": "delivered"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "delivery.opened": {
      "post": {
        "operationId": "onDeliveryOpened",
        "summary": "Delivery opened",
        "description": "The recipient opened the message, as reported by the sending provider.\n\n**Fires on every reported open.** The `opened_at` a delivery carries is\nthe first one only, so the two answer different questions. Open tracking\nis a pixel and is\nunreliable by nature: privacy proxies inflate it and image-blocking\nclients suppress it.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DeliveryOpenedEvent"
              },
              "examples": {
                "opened": {
                  "value": {
                    "id": "018f2a90-5555-7000-8000-000000000005",
                    "type": "delivery.opened",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T10:11:04Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "delivery_id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "email": "reader@example.com",
                      "provider_message_id": "4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4",
                      "status": "opened"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "domain.verified": {
      "post": {
        "operationId": "onDomainVerified",
        "summary": "Custom website domain verified",
        "description": "A creator's custom website domain went live: its `verification_status`\nbecame `active`. That state means ownership was validated, the\ncertificate was issued, and the hostname was confirmed to actually reach\nCommune. All three, so this is the point at which the domain serves the\nsite rather than merely resolving.\n\nThis is the rendering domain (a creator's own hostname serving their\nCommune site), not the email sending domain. That one is\n`sender.verified`.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DomainVerifiedEvent"
              },
              "examples": {
                "siteDomainActive": {
                  "value": {
                    "id": "018f2a92-1010-7000-8000-000000000010",
                    "type": "domain.verified",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T16:44:30Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "domain_id": "e4f50617-2839-4a4b-b5c6-d7e8f90a1b2c",
                      "domain": "blog.example.com",
                      "ssl_status": "active",
                      "verified_at": "2026-08-26T16:44:30Z"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "highlight.created": {
      "post": {
        "operationId": "onHighlightCreated",
        "summary": "Highlight created",
        "description": "A reader marked a passage of an article.\n\n**No `user_id`.** A highlight is never attributed to a named reader\nanywhere in Commune, so this payload carries the same opaque `owner_key`\n`GET /highlights/{highlight}` does. It is the one topic whose payload\ncannot be resolved back to a person.\n\n**Fires on a new highlight only.** Re-marking the same span of the same\narticle is silent. So is attaching a chat message to a highlight that\nalready exists, which is how a passage becomes a discussion; that fires\n`message.created` for the message instead.\n\nA highlight created together with its discussion carries\n`data.message_id` from the start. One created on its own carries null\nand never gains a value on this topic.\n\nA passage of a tag-scoped article can only be marked by someone in its\naudience.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HighlightCreatedEvent"
              },
              "examples": {
                "silentHighlight": {
                  "summary": "A personal mark with no discussion attached.",
                  "value": {
                    "id": "018f2a92-3232-7000-8000-000000000032",
                    "type": "highlight.created",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T19:11:27Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "highlight_id": "d4e5f607-1829-4a31-b2c3-d4e5f6071829",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "quote": "The moderation load is the product, not a tax on it.",
                      "start_offset": 4218,
                      "end_offset": 4271,
                      "owner_key": "4f2a9c1e7b3d6a05",
                      "message_id": null,
                      "created_at": "2026-08-26T19:11:27Z"
                    }
                  }
                },
                "highlightWithDiscussion": {
                  "summary": "A passage quoted into the article's chat thread, so the highlight carries the message it started.",
                  "value": {
                    "id": "018f2a92-3333-7000-8000-000000000033",
                    "type": "highlight.created",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T19:14:50Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "highlight_id": "e5f60718-2930-4b42-c3d4-e5f607182930",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "quote": "Community is a distribution channel that answers back.",
                      "start_offset": 812,
                      "end_offset": 860,
                      "owner_key": "9b1d0e6a3c4f27b8",
                      "message_id": "c2d3e4f5-0617-4829-93a4-b5c6d7e8f90a",
                      "created_at": "2026-08-26T19:14:50Z"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "import.completed": {
      "post": {
        "operationId": "onImportCompleted",
        "summary": "Import finished",
        "description": "A bulk ingest run finished. `data.kind` says which of the three it was.\n\n`articles`: an import a creator started against their connected provider\nfinished the batch they picked. `imported_count` is the number that\nactually landed.\n\n`subscribers`: a pull of every active contact from the connected\nprovider finished. An uploaded CSV ends the same way, with `source:\ncsv`.\n\n`migration`: the newsletter's `esp` became `commune`. That is the\ndestructive step of the move off a provider, and after it Commune sends\nthe newsletter itself.\n\n**This topic is an invalidation signal.** An import writes many records\nat once and fans out into no per-record events: it does not emit one\n`article.published` or one `subscriber.created` for each. This is the\nsingle message that says a newsletter changed underneath you, so refetch\non it.\n\n**Only a run that reached its end publishes here.** A partial run still\nfinishes and still fires, with a lower `imported_count` and no count of\nwhat it dropped. A run whose connection died mid-stream produces\nnothing, and there is no `import.failed` to pair with this.\n\nThere is no run identifier on this payload and no operation to look one\nup in. A run is identified by the newsletter, the kind and the source,\nand the thing to do with one is refetch.\n\nImports are idempotent and safe to re-run, so expect to see the same\nkind more than once for one newsletter.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ImportCompletedEvent"
              },
              "examples": {
                "subscribersPulledFromEsp": {
                  "summary": "A provider pull that finished. `imported_count` is what landed, which is smaller than the upstream list wherever a row was already there.",
                  "value": {
                    "id": "018f2a92-3434-7000-8000-000000000034",
                    "type": "import.completed",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T20:03:41Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "kind": "subscribers",
                      "source": "kit",
                      "imported_count": 4820,
                      "started_at": "2026-08-26T19:58:12Z",
                      "completed_at": "2026-08-26T20:03:41Z"
                    }
                  }
                },
                "articlesImported": {
                  "summary": "A batch of older articles pulled in from the provider.",
                  "value": {
                    "id": "018f2a92-3535-7000-8000-000000000035",
                    "type": "import.completed",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T20:21:09Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "kind": "articles",
                      "source": "kit",
                      "imported_count": 63,
                      "started_at": "2026-08-26T20:19:44Z",
                      "completed_at": "2026-08-26T20:21:09Z"
                    }
                  }
                },
                "migrationFinalized": {
                  "summary": "The newsletter is now `commune`. Nothing was ingested, so `imported_count` is null.",
                  "value": {
                    "id": "018f2a92-3636-7000-8000-000000000036",
                    "type": "import.completed",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T20:30:00Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "kind": "migration",
                      "source": null,
                      "imported_count": null,
                      "started_at": null,
                      "completed_at": "2026-08-26T20:30:00Z"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "message.created": {
      "post": {
        "operationId": "onMessageCreated",
        "summary": "Message created",
        "description": "A reply was posted inside a thread (`thread_level` 1 or 2). Replies\ninherit the placement of their thread and carry no visibility of their\nown.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MessageCreatedEvent"
              },
              "examples": {
                "reply": {
                  "value": {
                    "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"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "send.completed": {
      "post": {
        "operationId": "onSendCompleted",
        "summary": "Send completed",
        "description": "A send run finished handing every recipient to the email provider, and\nthe run was stamped with the `completed_at` this payload carries.\n\nThis is the dispatch milestone, not the delivery milestone. It says the\nprovider accepted the messages; whether they landed in inboxes is what\nthe `delivery.*` topics report, and those arrive later and one per\nrecipient.\n\nNamed for the send rather than the article: one article can have\nseveral runs across retries, each with its own id at `/sends/{send}`.\n`data.article_id` is there for anyone routing by article.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SendCompletedEvent"
              },
              "examples": {
                "sendFinished": {
                  "value": {
                    "id": "018f2a90-2222-7000-8000-000000000002",
                    "type": "send.completed",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T09:32:11Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "started_at": "2026-08-26T09:28:40Z",
                      "completed_at": "2026-08-26T09:32:11Z",
                      "recipient_count": 540,
                      "sent_count": 538,
                      "failed_count": 2
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "send.failed": {
      "post": {
        "operationId": "onSendFailed",
        "summary": "Send failed",
        "description": "A send run could not complete and the article was parked in `failed`,\nalongside the human-readable `failure_reason` this payload carries.\n\nRefusals that happen before dispatch starts, such as an unverified\nsender or a billing gate, are rejected synchronously when the send is\nrequested and never reach this topic. What lands here is a run that\nbegan and then broke, most commonly every delivery failing at the\nprovider.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SendFailedEvent"
              },
              "examples": {
                "everyDeliveryFailed": {
                  "value": {
                    "id": "018f2a90-3333-7000-8000-000000000003",
                    "type": "send.failed",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T09:33:02Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "failure_reason": "Every delivery failed."
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "sender.verified": {
      "post": {
        "operationId": "onSenderVerified",
        "summary": "Sender verified",
        "description": "A sending identity passed verification and the newsletter can send from\nit: its `verification_status` became `verified`. Two things reach that\nstate, a newly provisioned sender the provider already considered\nverified, and a check on an existing sender coming back clean.\n\nLoad-bearing rather than cosmetic: sending from an unverified sender is\nrefused with `sender_not_verified`, so this event is the signal that\nsending is unblocked.\n\nCommune re-checks sending identities in the background as well as when a\ncreator asks it to, so treat a repeat of this event for one sender as\nnormal rather than as a second, different verification. Failing\nverification is a real state too, but is not among the topics Commune\npublishes.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SenderVerifiedEvent"
              },
              "examples": {
                "customDomainVerified": {
                  "value": {
                    "id": "018f2a91-ffff-7000-8000-00000000000f",
                    "type": "sender.verified",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T16:00:12Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "sender_id": "d3e4f506-1728-4939-a4b5-c6d7e8f90a1b",
                      "from_email": "hello@mail.example.com",
                      "from_name": "The Example Letter",
                      "reply_to_email": null,
                      "domain": "mail.example.com",
                      "kind": "custom",
                      "is_default": true,
                      "verified_at": "2026-08-26T16:00:12Z"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "subscriber.created": {
      "post": {
        "operationId": "onSubscriberCreated",
        "summary": "Subscriber created",
        "description": "Someone became a subscriber of the newsletter. Four paths land here:\nsubscribing from inside the Commune app, finishing signup, accepting an\ninvitation, and a CSV or ESP import.\n\nOnly the import path records where they came from.\n`data.acquisition_source` carries the provider there and is null on the\nother three, so null is the normal case for anybody who arrived through\nCommune itself rather than a sign that the origin was lost.\n\n**Reactivation counts.** Unsubscribing does not delete a subscriber, so\na returning reader comes back to `subscribed` rather than being recorded\nagain. That still fires this topic, with `data.resubscribed: true`.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubscriberCreatedEvent"
              },
              "examples": {
                "subscribedOnCommune": {
                  "value": {
                    "id": "018f2a90-9999-7000-8000-000000000009",
                    "type": "subscriber.created",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T12:20:05Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "email": "reader@example.com",
                      "status": "subscribed",
                      "user_id": "usr_2Nf8Kq1pWc",
                      "acquisition_source": null,
                      "resubscribed": false,
                      "created_at": "2026-08-26T12:20:05Z"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "subscriber.status_changed": {
      "post": {
        "operationId": "onSubscriberStatusChanged",
        "summary": "Subscriber insight status changed",
        "description": "A reader crossed a boundary in the newsletter's engagement ladder:\n`reader` to `superfan`, or `engaged` to `dormant`. This is the moment a\nCRM or a re-engagement automation has something to do; the scores\nthemselves are on `GET /newsletters/{newsletter}/insights`.\n\nFired by a scheduled scoring pass, once per reader whose `status`\nactually differs from the one already stored, so a pass that changes\nonly the scores is silent.\n\n**`occurred_at` is when the crossing was computed, not when the reader\nacted.** `data.last_action_at` is the reader's own clock and sits\nearlier by up to a full scoring interval. Read that field to react while\nthe moment is still warm.\n\n**Status is a rank inside this newsletter, not an absolute score**, so a\nreader who did nothing can be demoted because the audience around them\ngot busier. Two events that look like opposite movements can arrive from\none pass without either reader having changed their behaviour.\n\nOnly readers with a Commune account are scored, so a subscriber the\nnewsletter knows only as an address never produces this event.\n\n**Not the same as `subscriber.unsubscribed`**, which reports the\nsubscription ending. `dormant` is a reader who went quiet, not one who\nleft.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubscriberStatusChangedEvent"
              },
              "examples": {
                "promotedToSuperfan": {
                  "value": {
                    "id": "018f2a91-dddd-7000-8000-00000000000d",
                    "type": "subscriber.status_changed",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-27T03:15:00Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "user_id": "usr_2Nf8Kq1pWc",
                      "email": "reader@example.com",
                      "previous_status": "reader",
                      "status": "superfan",
                      "direction": "promoted",
                      "total_score": 412,
                      "velocity": "rising",
                      "last_action_at": "2026-08-26T21:04:11Z"
                    }
                  }
                },
                "wentDormant": {
                  "value": {
                    "id": "018f2a91-eeee-7000-8000-00000000000e",
                    "type": "subscriber.status_changed",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-27T03:15:00Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "subscriber_id": "44556677-8899-4aa1-b2c3-d4e5f6071829",
                      "user_id": "usr_7Zx3Lm9qRt",
                      "email": "quiet@example.com",
                      "previous_status": "engaged",
                      "status": "dormant",
                      "direction": "demoted",
                      "total_score": 88,
                      "velocity": "cooling",
                      "last_action_at": "2026-07-02T09:12:40Z"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "subscriber.tagged": {
      "post": {
        "operationId": "onSubscriberTagged",
        "summary": "Subscriber tag added or removed",
        "description": "A tag was added to or taken off a subscriber. `data.direction` says\nwhich: `assigned` when the tag was put on, `removed` when it was taken\noff.\n\nOne topic carries both directions, so subscribing once is enough to\nmirror a segment's membership.\n\nApplying a tag is idempotent, so re-applying one the subscriber already\nholds produces no second event. **A tag is not a label**: it scopes who\nan article is sent to and who may read it, so treat these as access\nchanges.\n\n**Retiring the tag itself has no topic.** It retires the tag for\neveryone at once rather than removing one assignment, so it does not fan\nout into one event per subscriber here.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubscriberTaggedEvent"
              },
              "examples": {
                "tagAssigned": {
                  "value": {
                    "id": "018f2a91-bbbb-7000-8000-00000000000b",
                    "type": "subscriber.tagged",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T14:00:00Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "email": "reader@example.com",
                      "tag_id": "aa11bb22-cc33-4d44-8e55-ff6677889900",
                      "tag_name": "Founding member",
                      "direction": "assigned"
                    }
                  }
                },
                "tagRemoved": {
                  "value": {
                    "id": "018f2a91-cccc-7000-8000-00000000000c",
                    "type": "subscriber.tagged",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T14:05:00Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "email": "reader@example.com",
                      "tag_id": "aa11bb22-cc33-4d44-8e55-ff6677889900",
                      "tag_name": "Founding member",
                      "direction": "removed"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "subscriber.unsubscribed": {
      "post": {
        "operationId": "onSubscriberUnsubscribed",
        "summary": "Subscriber unsubscribed",
        "description": "A subscriber stopped being mailable. `data.reason` says why.\n\n`self_service`: the reader opted out themselves, either through the\nunsubscribe link in an email or the RFC 8058 one-click header, or from\ninside the Commune app.\n\n`bounced` and `complained`: forced by delivery telemetry rather than\nchosen by the reader, so they arrive alongside `delivery.bounced` or\n`delivery.complained`.\n\nThe subscriber record survives with its status changed, so bounce and\ncomplaint history is kept and a later resubscribe reuses it.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubscriberUnsubscribedEvent"
              },
              "examples": {
                "oneClickUnsubscribe": {
                  "value": {
                    "id": "018f2a91-aaaa-7000-8000-00000000000a",
                    "type": "subscriber.unsubscribed",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T13:41:52Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "email": "reader@example.com",
                      "reason": "self_service",
                      "unsubscribed_at": "2026-08-26T13:41:52Z",
                      "article_send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "thread.created": {
      "post": {
        "operationId": "onThreadCreated",
        "summary": "Thread created",
        "description": "A new top-level thread was started in a newsletter's space\n(`thread_level = 0`).\n\n`data.visibility` decides where it appears: `public` puts it on the\nglobal feed and only team members may set it, `subscribers` keeps it\ninside the newsletter's own space. `paid` exists in the enum and is not\nin use yet.\n\nA thread created public fires `thread.published` from the same write, so\na consumer that only cares about the feed can subscribe to that topic\nalone and ignore this one.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ThreadCreatedEvent"
              },
              "examples": {
                "articleDiscussion": {
                  "summary": "A thread bound to an article, which is where comments live now.",
                  "value": {
                    "id": "018f2a91-dddd-7000-8000-00000000000d",
                    "type": "thread.created",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T15:10:44Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "thread_id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9",
                      "short_id": "k3n8qz",
                      "url": "https://example.com/t/k3n8qz",
                      "author": {
                        "user_id": "usr_2Nf8Kq1pWc",
                        "username": "mira",
                        "display_name": "Mira Okafor",
                        "avatar_url": "https://cdn.example.com/avatars/mira.png"
                      },
                      "content": "The bit about moderation load matched my experience exactly.",
                      "visibility": "subscribers",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "created_at": "2026-08-26T15:10:44Z"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    },
    "thread.published": {
      "post": {
        "operationId": "onThreadPublished",
        "summary": "Thread published to the feed",
        "description": "A thread's `visibility` became `public`, which is what puts it on\nCommune's global feed. This is the only way community content leaves one\nnewsletter's space and reaches everyone, so it is the topic to watch for\nanything that mirrors, syndicates or moderates the feed.\n\nTwo paths reach `public` and both publish here, told apart by\n`data.source`:\n\n* `visibility_changed`: a team member featured a thread that already\n  existed. `published_by` and `published_at` say who and when.\n* `created_public`: the thread was born public, which a team member may\n  do when starting a top-level thread. That write fires `thread.created`\n  and this topic together, and carries no `published_by`.\n\nOnly an owner, admin or editor can reach `public`, and only on a\ntop-level thread: featuring anything with a non-zero `thread_level` is\nrefused, and replies inherit their thread's placement.\n\n**Going back to `subscribers` is a real change and has no topic.** A\nconsumer mirroring the feed should reconcile against\n`GET /threads/{thread}` rather than assume a thread it saw here is still\npublic.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n",
        "tags": [
          "Webhooks"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/WebhookVersion"
          },
          {
            "$ref": "#/components/parameters/WebhookEventId"
          },
          {
            "$ref": "#/components/parameters/WebhookEventType"
          },
          {
            "$ref": "#/components/parameters/WebhookSignature"
          },
          {
            "$ref": "#/components/parameters/WebhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/WebhookDeliveryAttempt"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ThreadPublishedEvent"
              },
              "examples": {
                "featuredByTheTeam": {
                  "summary": "An existing subscribers-only thread was featured onto the feed.",
                  "value": {
                    "id": "018f2a92-3030-7000-8000-000000000030",
                    "type": "thread.published",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T18:02:19Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "thread_id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9",
                      "short_id": "k3n8qz",
                      "url": "https://example.com/t/k3n8qz",
                      "author": {
                        "user_id": "usr_2Nf8Kq1pWc",
                        "username": "mira",
                        "display_name": "Mira Okafor",
                        "avatar_url": "https://cdn.example.com/avatars/mira.png"
                      },
                      "visibility": "public",
                      "previous_visibility": "subscribers",
                      "source": "visibility_changed",
                      "published_by": {
                        "user_id": "usr_5Qw8Hn2vFd",
                        "username": "sam",
                        "display_name": "Sam Ortega",
                        "avatar_url": null
                      },
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "published_at": "2026-08-26T18:02:19Z"
                    }
                  }
                },
                "startedPublic": {
                  "summary": "A team member started the thread public, so this arrives alongside thread.created from the same write.",
                  "value": {
                    "id": "018f2a92-3131-7000-8000-000000000031",
                    "type": "thread.published",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-26T18:40:03Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "thread_id": "c3d4e5f6-0718-4920-a1b2-c3d4e5f60718",
                      "short_id": "t9m4hx",
                      "url": "https://example.com/t/t9m4hx",
                      "author": {
                        "user_id": "usr_5Qw8Hn2vFd",
                        "username": "sam",
                        "display_name": "Sam Ortega",
                        "avatar_url": null
                      },
                      "visibility": "public",
                      "previous_visibility": null,
                      "source": "created_public",
                      "published_by": null,
                      "article_id": null,
                      "published_at": "2026-08-26T18:40:03Z"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"
          },
          "4XX": {
            "description": "The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"
          },
          "5XX": {
            "description": "The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "oauth2": {
        "type": "oauth2",
        "description": "An OAuth access token, sent as `Authorization: Bearer <token>`. The\nwalkthrough of the whole flow is at\n[usecommune.dev/use-cases/build-an-integration](https://usecommune.dev/use-cases/build-an-integration):\ndiscovery, registration, PKCE, the consent screen, the exchange, refresh\nand revocation.\n\nAsk for a family scope and the person picks which newsletter\nthe token reaches; ask for `account:read` alone and it reaches no\nnewsletter and reads only the account it belongs to.\n\nEach operation lists the scopes a token must carry to call it. An\noperation that lists none takes any token.\n\nDiscover the URLs under `flows` at runtime from\n`GET /.well-known/oauth-authorization-server` rather than hardcoding\nthem.\n",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://usecommune.com/api/oauth/authorize",
            "tokenUrl": "https://usecommune.com/api/oauth/token",
            "refreshUrl": "https://usecommune.com/api/oauth/token",
            "scopes": {
              "content:read": "Read articles, threads and the rest of what a newsletter publishes.",
              "content:write": "Create, edit and delete that content.",
              "audience:read": "Read subscribers, tags and segments, including email addresses.",
              "audience:write": "Add, tag and remove subscribers.",
              "insights:read": "Read engagement, delivery and growth figures.",
              "insights:write": "Write back an insight the newsletter owns.",
              "sending:read": "Read sends, schedules and delivery outcomes.",
              "sending:write": "Send an article, schedule one, and cancel a schedule.",
              "settings:read": "Read a newsletter's configuration, senders and domains.",
              "settings:write": "Change that configuration.",
              "webhooks:read": "Read event destinations and their delivery history.",
              "webhooks:write": "Create and remove event destinations.",
              "account:read": "Read the person the credential belongs to, and nothing about any newsletter."
            }
          }
        }
      },
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "Commune API key",
        "description": "A Commune API key, sent as `Authorization: Bearer <key>`. A key is\ngranted one or more newsletters and carries six permission families on\neach, every one of them `none`, `read` or `write`. An operation names\nthe family and the level it needs.\n\nA key is minted by a creator in Commune's settings: no flow, no consent\nscreen, no expiry. That is the whole difference from `oauth2`. An\noperation that declares both accepts either credential, and what each\nmay do is what it was granted.\n"
      }
    },
    "parameters": {
      "CommuneVersion": {
        "name": "Commune-Version",
        "in": "header",
        "required": false,
        "description": "The contract version this request is written against. Every version\npublished so far is a release date (`YYYY-MM-DD`), which is why the\nexamples look like one, but the value is an opaque identifier: match it\nagainst the versions this API publishes rather than parsing it, because\na future one may not be only a date. An unknown value answers `400`\nwith `invalid_version`.\n\nOmitting the header pins the request to the version that was current\nwhen the API key was issued, so an integration keeps working when a\nnewer version ships.\n",
        "schema": {
          "type": "string",
          "minLength": 1,
          "examples": [
            "2026-08-26"
          ]
        }
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "description": "A value of your choosing naming the change this request is making.\n\nSend the same value again to retry the same request. Commune replays\nthe answer the first attempt gave instead of making the change twice,\nand marks the replay with an `Idempotent-Replay: true` response header.\nSend a different value for a different change: a key reused for a\nrequest that differs in any way answers `409`, because replaying an\nanswer to a question you did not ask is a wrong answer you could not\ndetect.\n\nA UUID per change is the usual choice. Remembered for 24 hours, per\ncredential, so two credentials choosing the same value never see each\nother's answers.\n\nRequired, not optional.\n",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 255
        },
        "examples": {
          "uuid": {
            "summary": "A UUID per change",
            "value": "3f7c1a26-9b0e-4f5a-9a2c-2c8f1d6b4e77"
          }
        }
      },
      "NewsletterChoice": {
        "name": "newsletter",
        "in": "query",
        "required": false,
        "description": "Which newsletter this request is for, by `id` or by `handle`.\n\nMost operations never need this. A credential reaches a list of\nnewsletters, and an operation that acts on one of them normally works\nout which from the object in its path: an article, a thread, a subscriber\nand a key each belong to a newsletter, so naming one is naming the\nother. This parameter is for the operations whose subject is **not** a\nCommune object, where there is nothing to work it out from.\n\nLeave it out if your credential reaches exactly one newsletter, which is\nthe usual case: it is that one. If your credential reaches several and\nyou leave it out, the answer is `400` naming this parameter, because\npicking one for you would be picking wrong most of the time.\n\n`GET /newsletters` lists the newsletters your credential reaches, and is\nwhere the value for this comes from.\n",
        "schema": {
          "type": "string",
          "maxLength": 200
        },
        "examples": {
          "byHandle": {
            "summary": "By handle",
            "value": "the-weekly"
          },
          "byId": {
            "summary": "By UUID",
            "value": "9a4c1c6e-0f2b-4f47-9d3f-6d1b1a2c3d4e"
          }
        }
      },
      "NewsletterPath": {
        "name": "newsletter",
        "in": "path",
        "required": true,
        "description": "The newsletter's `id` (a UUID) or its `handle`. A handle is unique\nacross Commune and is the identifier its public web profile uses, so it\nis the one to hardcode in an integration.\n",
        "schema": {
          "type": "string"
        },
        "examples": {
          "byId": {
            "summary": "By UUID",
            "value": "9a4c1c6e-0f2b-4f47-9d3f-6d1b1a2c3d4e"
          },
          "byHandle": {
            "summary": "By handle",
            "value": "the-weekly"
          }
        }
      },
      "ArticlePath": {
        "name": "article",
        "in": "path",
        "required": true,
        "description": "The article's `id` (a UUID) or its `short_id`, an eight character base62\nstring that is unique across Commune. The `slug` is not accepted here\nbecause it is unique only within a newsletter.\n",
        "schema": {
          "type": "string"
        },
        "examples": {
          "byShortId": {
            "summary": "By short id",
            "value": "k7Rm2xQp"
          }
        }
      },
      "SenderPath": {
        "name": "sender",
        "in": "path",
        "required": true,
        "description": "The sending address's `id`. It has no short id.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "DomainPath": {
        "name": "domain",
        "in": "path",
        "required": true,
        "description": "The website domain's `id`, or the hostname itself. The hostname is\nunique across Commune, so either resolves.\n",
        "schema": {
          "type": "string"
        },
        "examples": {
          "byHostname": {
            "summary": "By hostname",
            "value": "blog.example.org"
          }
        }
      },
      "HighlightPath": {
        "name": "highlight",
        "in": "path",
        "required": true,
        "description": "The highlight's `id`. A highlight has no short id.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "ApiKeyPath": {
        "name": "key",
        "in": "path",
        "required": true,
        "description": "The API key's `id`. Never the secret itself: Commune does not store one\nand could not look a key up by one, and a credential that travelled in a\nURL would end up in access logs and browser history.\n",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "TagPath": {
        "name": "tag",
        "in": "path",
        "required": true,
        "description": "The tag's `id`. A tag has no short id.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "DeliveryAttemptPath": {
        "name": "attempt",
        "in": "path",
        "required": true,
        "description": "The delivery attempt's `id`, as\n`GET /newsletters/{newsletter}/delivery-attempts` returned it. Opaque,\nand minted by the delivery service rather than by Commune, so it is not\na UUID and must not be parsed as one.\n",
        "schema": {
          "type": "string"
        }
      },
      "SubscriberPath": {
        "name": "subscriber",
        "in": "path",
        "required": true,
        "description": "The subscriber's `id`. This identifies one person's membership of one\nnewsletter, not the person, and a subscriber has no short id.\n",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "ThreadPath": {
        "name": "thread",
        "in": "path",
        "required": true,
        "description": "The thread's `id` (a UUID) or its `short_id`. A thread that Commune\nopened under an article has no `short_id`, because it is addressed on\nthe web through the article's own permalink, so use its `id`.\n",
        "schema": {
          "type": "string"
        },
        "examples": {
          "byShortId": {
            "summary": "By short id",
            "value": "b3Xn8kTw"
          }
        }
      },
      "MessagePath": {
        "name": "message",
        "in": "path",
        "required": true,
        "description": "The message's `id` (a UUID) or its `short_id`.",
        "schema": {
          "type": "string"
        }
      },
      "UserPath": {
        "name": "user",
        "in": "path",
        "required": true,
        "description": "The user's `id` or their `username`, with or without a leading `@`.",
        "schema": {
          "type": "string"
        },
        "examples": {
          "byUsername": {
            "summary": "By username",
            "value": "@ada"
          }
        }
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "The `pagination.next_cursor` value from the previous page. Omit it to\nread the first page. A cursor is opaque, is only valid for the same\noperation with the same filters, and is not a durable identifier.\n",
        "schema": {
          "type": "string",
          "maxLength": 512
        }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "description": "How many items to return in this page. This is a page size, not an\noffset. Fewer items than requested may come back and that does not mean\nthe collection is exhausted, only an absent `next_cursor` does.\n",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 20
        }
      },
      "Expand": {
        "name": "expand",
        "in": "query",
        "required": false,
        "description": "Comma-separated list of relationship paths to inline in the response.\nUnexpanded relationships are returned as a reference object carrying\nonly `id` and `object`. Each operation documents the paths it accepts,\nand an unknown path answers `400`. Nested paths use a dot, for example\n`article.newsletter`.\n",
        "schema": {
          "type": "string"
        },
        "examples": {
          "singleRelation": {
            "summary": "Inline the newsletter of each item",
            "value": "newsletter"
          },
          "nestedRelation": {
            "summary": "Inline the newsletter of the article of each item",
            "value": "article.newsletter"
          },
          "secondRendition": {
            "summary": "Add the Markdown rendition of an article body",
            "value": "content"
          }
        }
      },
      "Fields": {
        "name": "fields",
        "in": "query",
        "required": false,
        "description": "Comma-separated allow-list of top level properties to return on each\nobject, so a client can trim a response it does not need in full. `id`\nand `object` are always returned. An unknown property name answers\n`400`. Properties omitted by an operation, such as `content` on any\narticle list, cannot be brought back with `fields`.\n",
        "schema": {
          "type": "string"
        },
        "examples": {
          "trimmed": {
            "summary": "Only the fields a link list needs",
            "value": "title,slug,posted_at"
          }
        }
      },
      "SendPath": {
        "name": "send",
        "in": "path",
        "required": true,
        "description": "The send's `id`, which is the `send_id` a `send.completed` or\n`send.failed` event carries. A send has no short id.\n",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "StatsPeriod": {
        "name": "period",
        "in": "query",
        "required": false,
        "description": "A named window, counted back from today in UTC. Defaults to `30d`.\nIgnored when `since` is given, so a request never has to reconcile two\nconflicting windows.\n",
        "schema": {
          "type": "string",
          "enum": [
            "7d",
            "14d",
            "30d",
            "90d",
            "6m",
            "1y",
            "all"
          ],
          "default": "30d"
        }
      },
      "StatsSince": {
        "name": "since",
        "in": "query",
        "required": false,
        "description": "Start of the window, inclusive, as a date or an RFC 3339 timestamp.\nGiving this overrides `period`. A `since` later than `until` answers\n`400`. Anything that is neither of those two shapes answers `400` as\nwell, rather than being guessed at: a lenient parser would read\n`8/1/2026` as a date and give two callers different windows for the same\nstring.\n",
        "schema": {
          "type": "string",
          "maxLength": 40
        },
        "examples": {
          "asDate": {
            "summary": "A calendar day",
            "value": "2026-08-01"
          }
        }
      },
      "StatsUntil": {
        "name": "until",
        "in": "query",
        "required": false,
        "description": "End of the window, exclusive, as a date or an RFC 3339 timestamp.\nDefaults to now. Only meaningful alongside `since`.\n",
        "schema": {
          "type": "string",
          "maxLength": 40
        },
        "examples": {
          "asDate": {
            "summary": "A calendar day",
            "value": "2026-09-01"
          }
        }
      },
      "WebhookVersion": {
        "name": "Commune-Version",
        "in": "header",
        "required": true,
        "description": "API version the payload conforms to. Same value as `api_version` in the envelope.",
        "schema": {
          "type": "string",
          "examples": [
            "2026-08-26"
          ]
        }
      },
      "WebhookEventId": {
        "name": "Commune-Event-Id",
        "in": "header",
        "required": true,
        "description": "Same value as `id` in the envelope. The dedupe key.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "WebhookEventType": {
        "name": "Commune-Event-Type",
        "in": "header",
        "required": true,
        "description": "Same value as `type` in the envelope. The topic name.",
        "schema": {
          "type": "string"
        }
      },
      "WebhookSignature": {
        "name": "Commune-Signature",
        "in": "header",
        "required": true,
        "description": "HMAC-SHA256 over `Commune-Timestamp` as Unix seconds, a literal `.`, then the raw request body, keyed by the endpoint's signing secret. Lowercase hex, carried as `v0=` followed by one or more comma separated digests (more than one while a secret is being rotated). The header's timestamp is an RFC 3339 instant, so convert it to Unix seconds before signing. Verify against the raw bytes before parsing the JSON. The webhooks guide has the full procedure.",
        "schema": {
          "type": "string",
          "examples": [
            "v0=8501d20988a42e76a6f63c4aaabbca745b9ad6333c254c1665b970a7e3c39541"
          ]
        }
      },
      "WebhookTimestamp": {
        "name": "Commune-Timestamp",
        "in": "header",
        "required": true,
        "description": "When this delivery attempt was made, as an RFC 3339 instant in UTC. It differs on every retry, and it IS covered by the signature, in Unix seconds, so a stale or altered one can be rejected. Enforce a tolerance window of a few minutes and dedupe on the event id as well, since a retry inside that window is legitimate.",
        "schema": {
          "type": "string",
          "format": "date-time",
          "examples": [
            "2026-08-26T09:32:14Z"
          ]
        }
      },
      "WebhookDeliveryAttempt": {
        "name": "Commune-Delivery-Attempt",
        "in": "header",
        "required": false,
        "description": "1 on the first attempt, incremented on each retry. **Not sent today**: do not rely on it, and be idempotent regardless. The same number is readable after the fact as `attempt` on a `DeliveryAttempt`, so a consumer that needs to tell a retry from a first delivery can ask instead of being told.",
        "schema": {
          "type": "integer",
          "minimum": 1
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request was malformed, and the same request will fail the same way\nuntil it is changed. `param` names the parameter or header at fault\nwhen there is exactly one, and `allowed_values` lists what it accepts\nwhen that is a finite set. The code is `bad_request` for every case\nbelow except the last.\n\n* **A query parameter**: one the operation does not have, a value\n  outside its set, range or format (an unparseable cursor, an unknown\n  `expand` path or `fields` name, an identifier that is not a UUID),\n  or a required one left out, such as `q` on a search or `newsletter`\n  when the credential reaches more than one.\n* **The request body**: not JSON, not the shape the operation reads,\n  a property it does not write, or a value of the wrong type, length\n  or format. `param` is absent here, since the body is not a\n  parameter, and the message names the property.\n* **The `Idempotency-Key` header**, on an operation that changes\n  something: missing, or a value this API will not store.\n* **An unrecognised `Commune-Version`**, which answers with its own\n  code, `invalid_version`, because it is never fixed by changing the\n  body.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "No credential was presented, or it is malformed, unknown, revoked or\nexpired, or it is an access token minted for a different audience.\n\nEvery one of these answers identically, down to the wording and the\nheaders, so a refusal never confirms that a string was once real.\n",
        "headers": {
          "WWW-Authenticate": {
            "description": "The authentication scheme this API accepts, and where to find out\nhow to get a credential for it. Always\n`Bearer realm=\"Commune API\", resource_metadata=\"https://api.usecommune.com/.well-known/oauth-protected-resource\"`.\n\n`resource_metadata` is the RFC 9728 pointer to this API's protected\nresource metadata, which names the authorization server an OAuth\nclient should send its user to. A client holding an API key can\nignore it. The header carries no `error` parameter, not even\n`error=\"invalid_token\"`, because it describes what this API accepts\nrather than what was wrong with the credential sent, and the\nreasons above are deliberately indistinguishable.\n\nThere is no second scheme and no query-parameter fallback, because\na credential that can travel in a URL ends up in access logs and\nreferer headers.\n",
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "The credential is valid but is not allowed to do this. Two codes answer\nwith this status, and `error.code` says which.\n\n**`insufficient_scope`: it does not hold the permission.** The\noperation needs, say, `audience: read` on the newsletter addressed, and\nthis credential holds less than that there. `allowed_values` carries\nthe permission that was needed, and the message says what the\ncredential does hold on that newsletter, because a credential granted\nthe wrong family and a credential belonging to somebody whose standing\non the team has narrowed look identical without it. The answer can\ndiffer per newsletter: the same credential may be allowed here and\nrefused on the next one it reaches.\n\nThe same code answers an operation that needs the **account\npermission** from a credential that does not carry it. That permission\nis about the person a credential belongs to rather than about any\nnewsletter, so nothing granted on a newsletter adds up to it. It is\ngranted on the credential itself, when a key is minted or when an\nauthorization asks for `account:read`.\n\nAnd it answers a parameter the credential may send, but not with the\nvalue it sent: a filter a credential holding only `read` permissions\nmay not use, or an `expand` path whose rows need a permission the\noperation does not. `param` names the parameter, and `allowed_values`\ncarries what this credential may send instead, or is absent when it may\nsend nothing there at all.\n\n**`forbidden`: it may not act here at all.** Either the credential does\nnot reach the newsletter addressed, because it was never granted it or\nbecause the person it belongs to can no longer act on it, or it reaches\nno newsletter at all; `param` is `newsletter`, and `GET /newsletters`\nlists the ones it does reach. Or, on `DELETE /api-keys/{key}`, the\ncredential named belongs to somebody else. Neither carries\n`allowed_values`, because there is no value to send instead.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "No such resource, or the key is not allowed to know that it exists.\nCommune answers `404` rather than `403` where distinguishing the two\nwould leak the existence of private content.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "PaymentRequired": {
        "description": "The credential is allowed to do this but the newsletter's plan does not\ninclude it.\n\nTwo surfaces can answer it: **insights**, the engagement and metrics\noperations, which are the only reads Commune reserves the right to\nmeter, and **writing**, every operation that changes something.\n\nEvery other read stays free on every plan, so a credential refused at\none of these can still read everything else. The body names the plan the\nnewsletter is on and the plans that would work.\n\n**This status is predictable and should not be how you discover it.**\n`GET /newsletters/{newsletter}/entitlements` answers the same question\nin advance, carrying the same plan list this puts in `allowed_values`\nand the same sentence it puts in `message`. Read it once at the start\nof a run rather than finding out in the middle of one.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Conflict": {
        "description": "The request collided with something. On a write this is always the\n`Idempotency-Key`, in one of two ways, and the message says which.\n\nEither the key was already used for a **different** request, which is\nrefused rather than answered with the earlier request's result. Or an\nearlier request using the same key has not finished, or never reported\nan outcome, in which case this one was not run and the key becomes\nusable again shortly.\n\nNothing was changed by a request that answers this.\n",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying, on the second case only.\n",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unprocessable": {
        "description": "The request is well formed and every value in it is legal, and the\nstate of what it addresses refuses it anyway. The message says what\nabout that state is in the way.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests. Back off and retry after the interval named by the\n`Retry-After` response header.\n\nOne of the budgets in `RateLimit-Policy` ran out, and the\n`RateLimit-*` headers on this response say which and when it resets.\n",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "SendLimited": {
        "description": "Too many requests. Back off and retry after the interval named by the\n`Retry-After` response header.\n\nUsually one of the budgets in `RateLimit-Policy` ran out, and the\n`RateLimit-*` headers on this response say which and when it resets.\n\nThis operation can also reach a **daily send limit**, which is counted\napart from those budgets and is not reported in them: how many times an\narticle may be dispatched to a newsletter's whole list in a day, or how\nmany test copies a credential may send to addresses it names. Neither\ncounts recipients, so the size of a send is never what refuses it. When\none of these is what answered, the message says so by name and\n`Retry-After` is measured in hours rather than seconds, which is how to\ntell the two apart.\n",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "InternalError": {
        "description": "Something failed inside Commune. The request may be retried.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "A capability this operation depends on did not answer. Every other\noperation is unaffected, so back off on this one rather than on the API.\n\nTwo parts of the API can answer this, because they are the only ones\nCommune cannot serve out of its own database.\n\n**Event delivery.** Destinations, the attempt log and the portal all\nlive in the delivery service. It is never an empty answer instead,\nbecause a destination list or an attempt log that came back empty for\nthis reason reads exactly like a newsletter that has registered no\nendpoints and sent nothing anywhere.\n\n**`sendArticleTest`.** A test copy is sent while the request is open,\nby Commune's sending service, and this answers when that service could\nnot be reached or when the sending provider refused every address on\nthe test, so nothing arrived. Nothing about the article changes either\nway, and the message says which of the two happened.\n",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying. Absent in the one case that will\nnot pass on its own, a deployment where event delivery is not\navailable at all; the message says so, and retrying will not clear\nit.\n",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Pagination": {
        "type": "object",
        "title": "Pagination",
        "description": "Cursor pagination state. Commune never exposes an offset or a page\nnumber: a collection is a moving window, and an offset silently skips or\nrepeats items when the window shifts between two requests.\n",
        "additionalProperties": false,
        "required": [
          "has_more",
          "next_cursor"
        ],
        "properties": {
          "has_more": {
            "type": "boolean",
            "description": "Whether another page exists. When `false`, `next_cursor` is `null`.\n"
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Pass this back as `?cursor=` to read the next page. `null` on the\nlast page. Opaque, and valid only for the same operation with the\nsame filters.\n",
            "examples": [
              "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
            ]
          }
        }
      },
      "ListEnvelope": {
        "type": "object",
        "title": "ListEnvelope",
        "description": "The envelope every collection is returned in. `data` holds the page,\n`pagination` holds the cursor state.\n\n`data` is required here and typed by each list operation, as an array\nof the one thing that operation returns, so the item type is stated on\nthe page you are reading.\n",
        "required": [
          "object",
          "data",
          "pagination"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "list",
            "description": "Always `list`, so a response is self describing."
          },
          "pagination": {
            "$ref": "#/components/schemas/Pagination"
          }
        }
      },
      "Ref": {
        "type": "object",
        "title": "Ref",
        "description": "An unexpanded relationship. Ask for the relationship in `?expand=` to\nget the full object in its place.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id"
        ],
        "properties": {
          "object": {
            "type": "string",
            "description": "The type of the referenced resource.",
            "examples": [
              "newsletter"
            ]
          },
          "id": {
            "type": "string",
            "description": "The referenced resource's `id`, in whatever form that resource's own\nschema declares. Most are UUIDs; a `Ref` whose `object` is `user`\ncarries an account identifier, which is an opaque string and not a\nUUID. Compare it for equality and pass it back; do not parse it.\n",
            "examples": [
              "9a4c1c6e-0f2b-4f47-9d3f-6d1b1a2c3d4e"
            ]
          }
        }
      },
      "Error": {
        "type": "object",
        "title": "Error",
        "description": "The error envelope. Every non `2xx` response from every operation has\nthis shape, so a client can branch on `error.code` without knowing which\noperation produced it.\n",
        "additionalProperties": false,
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "$ref": "#/components/schemas/ErrorCode"
              },
              "message": {
                "type": "string",
                "description": "A human readable sentence describing what went wrong. Written\nfor a developer reading a log, not for an end user. Do not\nbranch on it, branch on `code`.\n",
                "examples": [
                  "Newsletter not found."
                ]
              },
              "param": {
                "type": "string",
                "description": "The query, path or body parameter the error is attributed to,\nwhen the error is attributable to exactly one. Absent otherwise.\n",
                "examples": [
                  "cursor"
                ]
              },
              "allowed_values": {
                "type": "array",
                "description": "Everything `param` would have accepted, when what it accepts is\na finite set. Absent when it is not: a cursor, an identifier or\na numeric range has nothing to enumerate, and an empty array\nwould read as \"nothing is allowed\".\n\nIt repeats what `message` says in prose, so a caller can correct\na request from this one response: the array is what a program\nbranches on, the sentence is what a person or a model reads.\n\nOn an unknown parameter name rather than an unknown value, this\ncarries the parameter names the operation does accept, since\nthat is the set the caller has to pick from.\n\nOn an `insufficient_scope` failure there is usually no\nparameter at fault and `param` is absent, and this carries the\none permission that was needed, written the way the permission\ntable writes it, such as `content: read`. The exception is a\ncredential that may call the operation but not with one value\nof a parameter, such as `?expand=subscriber` on\n`listNewsletterInsights` without `audience: read`: then `param`\nnames the parameter and this carries the values this credential\nmay send instead.\n",
                "items": {
                  "type": "string"
                },
                "examples": [
                  [
                    "subscribed",
                    "unsubscribed",
                    "bounced",
                    "complained",
                    "pending"
                  ]
                ]
              },
              "request_id": {
                "type": "string",
                "description": "Identifier for this request, echoed in the `Commune-Request-Id`\nresponse header. Quote it in support requests.\n",
                "examples": [
                  "req_01j9c8h1q7m3n4p5r6s7t8u9v0"
                ]
              },
              "docs_url": {
                "type": "string",
                "format": "uri",
                "description": "Link to the documentation for this error code: always\n`https://usecommune.dev/errors/` followed by the code, a page\non what the code means, what usually causes it and how to fix\nit.\n",
                "examples": [
                  "https://usecommune.dev/errors/not_found"
                ]
              }
            }
          }
        }
      },
      "ErrorCode": {
        "type": "string",
        "title": "ErrorCode",
        "description": "The stable, machine readable reason a request failed. New codes may be\nadded in a minor version, so treat an unrecognised code as a generic\nfailure of its HTTP status class.\n\nTwo of these share a status with a neighbour and exist because what a\ncaller does next is different. `invalid_version` is a `400` that is\nnever fixed by changing the request body. `not_commune_newsletter` is a\n`422` that is never fixed by changing the request at all: it says the\nnewsletter's articles are published somewhere else and mirrored into\nCommune afterwards, so Commune cannot write one. Its page at\n`https://usecommune.dev/errors/not_commune_newsletter`, like every\ncode's, is its `docs_url`, and it covers moving a newsletter onto\nCommune's own publishing, which is the only thing that resolves it.\n",
        "enum": [
          "bad_request",
          "invalid_version",
          "unauthorized",
          "forbidden",
          "insufficient_scope",
          "payment_required",
          "not_found",
          "conflict",
          "unprocessable",
          "not_commune_newsletter",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      },
      "DependencyState": {
        "type": "string",
        "title": "DependencyState",
        "description": "How a single dependency answered its last check. `up` is a successful\nanswer, `degraded` is an answer that arrived but was slow or partial,\nand `down` is no usable answer at all.\n\n`down` covers every way a dependency can be unavailable to this\ndeployment and does not distinguish between them. Read it as \"not\nusable right now\", never as a statement about why.\n",
        "enum": [
          "up",
          "degraded",
          "down"
        ]
      },
      "Dependency": {
        "type": "object",
        "title": "Dependency",
        "description": "One capability the API depends on, and how it answered. `required` is\nthe field that matters when deciding what to do about a failure: a\nrequired dependency being down means no operation can be served, while\nan optional one being down costs only the operations that touch it.\n\nA dependency is named by the capability it provides, never by the\nvendor providing it, and carries no free text. `GET /status` is\nunauthenticated, so its body is held to answering whether the API can\nserve. Anything finer, including why a dependency is `down`, is an\noperator concern and is not published here.\n",
        "additionalProperties": false,
        "required": [
          "name",
          "state",
          "required",
          "checked_at"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Which capability this is. Stable across versions, so it is safe to\nbranch on: the name says what the dependency does, not who provides\nit, so changing a provider does not change the name.\n",
            "enum": [
              "database"
            ]
          },
          "state": {
            "$ref": "#/components/schemas/DependencyState"
          },
          "required": {
            "type": "boolean",
            "description": "Whether the API can serve at all without it. Commune's own store\nis required, and is currently the only dependency this API has: the\ncapabilities behind sending, billing and domain provisioning are\nserved by a different deployment and are not reported here. The\nfield stays because an optional dependency may be added again, and\na caller should already be branching on it rather than on the\nlength of the list.\n"
          },
          "checked_at": {
            "type": "string",
            "format": "date-time",
            "description": "When this dependency was last checked. Checks are cached for a few\nseconds, so this is usually a little behind the request. Always\npresent, whatever the state.\n"
          }
        }
      },
      "RateLimitPolicy": {
        "type": "object",
        "title": "RateLimitPolicy",
        "description": "One budget a key is measured against. A request is charged to every\nbudget that applies to its operation, and has to pass all of them.\n",
        "additionalProperties": false,
        "required": [
          "name",
          "limit",
          "used",
          "remaining",
          "window_seconds",
          "reset_at",
          "description"
        ],
        "properties": {
          "name": {
            "type": "string",
            "enum": [
              "general",
              "audience",
              "write"
            ],
            "description": "`general` counts every request made with the key. `audience` counts\nonly the operations that return subscriber or recipient email\naddresses. `write` counts only the operations that change\nsomething. Both of the narrow budgets are tighter than `general`,\nand an operation they apply to is charged to `general` as well and\nhas to pass both.\n\nNew budget names may be added in a minor version, so treat one you\ndo not recognise as a budget that does not concern you rather than\nas an error.\n"
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "description": "Requests this budget allows per window."
          },
          "used": {
            "type": "integer",
            "minimum": 0,
            "description": "Requests counted in the current window, including the one that asked.\n"
          },
          "remaining": {
            "type": "integer",
            "minimum": 0,
            "description": "Requests left in the current window. Zero means the next request\ncharged to this budget answers `429`.\n"
          },
          "window_seconds": {
            "type": "integer",
            "minimum": 1,
            "description": "How long a window lasts. A window is anchored to the first request\nthat opened it rather than to the clock, so it does not reset on the\nminute.\n"
          },
          "reset_at": {
            "type": "string",
            "format": "date-time",
            "description": "When this budget's window resets and `used` returns to zero. The\nsame instant `RateLimit-Reset` reports as a number of seconds.\n"
          },
          "description": {
            "type": "string",
            "description": "What this budget counts, in one line."
          }
        }
      },
      "RateLimit": {
        "type": "object",
        "title": "RateLimit",
        "description": "A key's rate limit state. The top level fields repeat the `general`\nbudget, which every request is charged to; `policies` carries every\nbudget, which is what a client should read before deciding it has been\ncut off.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "limit",
          "remaining",
          "window_seconds",
          "reset_at",
          "policies"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "rate_limit",
            "description": "Always `rate_limit`."
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "description": "The `general` budget's limit, repeated for convenience."
          },
          "remaining": {
            "type": "integer",
            "minimum": 0,
            "description": "Requests left on the `general` budget in this window."
          },
          "window_seconds": {
            "type": "integer",
            "minimum": 1,
            "description": "The `general` budget's window length."
          },
          "reset_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the `general` budget's window resets."
          },
          "policies": {
            "type": "array",
            "description": "Every budget this key is measured against, `general` first. A budget\nthat does not apply to any operation the key may call is still\nlisted, because what it counts is a property of the API rather than\nof the key.\n",
            "items": {
              "$ref": "#/components/schemas/RateLimitPolicy"
            }
          }
        }
      },
      "ServiceStatus": {
        "type": "object",
        "title": "ServiceStatus",
        "description": "The service's own health, the state of what it depends on, and the\ncontract version it is currently serving.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "status",
          "version",
          "dependencies"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "service_status",
            "description": "Always `service_status`."
          },
          "status": {
            "type": "string",
            "description": "The whole service in one word, driven by the required dependencies\nalone. `ok` when every required dependency is up, `degraded` when\none answered slowly or partially, and `down` when one is\nunreachable. A `degraded` service still answers `200` here, because\nthe point of this operation is to say so.\n\nAn optional dependency being `down` would not move this: the\noperations that need one refuse individually. There is no optional\ndependency today. Read `dependencies` for the individual states.\n",
            "enum": [
              "ok",
              "degraded",
              "down"
            ]
          },
          "version": {
            "type": "string",
            "minLength": 1,
            "description": "The newest contract version this service serves. An opaque\nidentifier rather than a date, even though every version published\nso far is one: compare it for equality with the version a request\nresolved to, and do not parse it.\n\nA request that sends no `Commune-Version` header is not necessarily\non it: an existing key stays pinned to the version that was current\nwhen it was issued. Compare the two to find out whether an\nintegration has a newer contract available to move to.\n",
            "examples": [
              "2026-08-26"
            ]
          },
          "dependencies": {
            "type": "array",
            "description": "Every dependency the API checks, whatever its state. The set is\nfixed by this contract rather than by the deployment, so the list\nis the same length on every response from every environment. Order\nis not meaningful; match on `name` rather than on position, and do\nnot assume a length: entries may be added or removed as what this\nAPI depends on changes.\n",
            "items": {
              "$ref": "#/components/schemas/Dependency"
            }
          }
        }
      },
      "Esp": {
        "type": "string",
        "title": "Esp",
        "description": "Where a newsletter is published from. `commune` means Commune itself\nsends the email. Every other value is an email service provider whose\nposts Commune imports. `rss` covers any feed that is not one of the\nnamed providers.\n",
        "enum": [
          "commune",
          "beehiiv",
          "buttondown",
          "ghost",
          "kit",
          "mailchimp",
          "mailerlite",
          "rss",
          "substack"
        ]
      },
      "SocialLinks": {
        "type": "object",
        "title": "SocialLinks",
        "description": "The creator's other homes on the internet, stored as canonical profile\nURLs. Every key is optional and a newsletter that set none returns an\nempty object.\n",
        "additionalProperties": false,
        "properties": {
          "twitter": {
            "type": "string",
            "format": "uri",
            "description": "X or Twitter profile URL."
          },
          "bluesky": {
            "type": "string",
            "format": "uri",
            "description": "Bluesky profile URL."
          },
          "linkedin": {
            "type": "string",
            "format": "uri",
            "description": "LinkedIn profile URL."
          },
          "mastodon": {
            "type": "string",
            "format": "uri",
            "description": "Mastodon profile URL, including the instance host."
          },
          "youtube": {
            "type": "string",
            "format": "uri",
            "description": "YouTube channel URL."
          },
          "instagram": {
            "type": "string",
            "format": "uri",
            "description": "Instagram profile URL."
          },
          "threads": {
            "type": "string",
            "format": "uri",
            "description": "Threads profile URL."
          },
          "github": {
            "type": "string",
            "format": "uri",
            "description": "GitHub profile URL."
          }
        }
      },
      "Newsletter": {
        "type": "object",
        "title": "Newsletter",
        "description": "A newsletter and its public profile. Nothing operational is exposed:\nESP credentials, OAuth tokens, group and audience ids, feed polling\nstate and language detection bookkeeping all stay server side.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "handle",
          "name",
          "esp",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "newsletter",
            "description": "Always `newsletter`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "handle": {
            "type": "string",
            "description": "The short, unique, URL safe name. Resolves the public profile at\n`/n/{handle}` and is accepted anywhere `{newsletter}` is.\n",
            "examples": [
              "the-weekly"
            ]
          },
          "name": {
            "type": "string",
            "description": "Display name, as the creator writes it."
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "The profile blurb. Sanitised HTML, not plain text, because creators\nformat it. Treat it as untrusted markup and render it in a\nsandboxed context.\n"
          },
          "esp": {
            "$ref": "#/components/schemas/Esp"
          },
          "image_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Square avatar for the newsletter."
          },
          "website_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "The creator's own site, if they linked one."
          },
          "social_links": {
            "$ref": "#/components/schemas/SocialLinks"
          },
          "language": {
            "type": [
              "string",
              "null"
            ],
            "description": "Best known language of the newsletter's writing as a BCP 47 tag.\nDetected from recent articles rather than declared, so treat it as a\nhint. Null before enough has been published to tell.\n",
            "examples": [
              "en"
            ]
          },
          "chat_create_permission": {
            "type": "string",
            "enum": [
              "editors",
              "subscribers",
              "anyone"
            ],
            "description": "Who may start a new chat thread in this community.\n"
          },
          "allow_non_subscriber_chat": {
            "type": "boolean",
            "description": "Whether people who have not subscribed may reply in existing\nthreads.\n"
          },
          "owner": {
            "description": "The account that owns the newsletter. A `Ref` unless `owner` is\nnamed in `?expand=`.\n",
            "anyOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/User"
              }
            ]
          },
          "featured_article": {
            "description": "The article the creator pinned to the top of the profile, or `null`\nwhen none is pinned. A `Ref` unless `featured_article` is named in\n`?expand=`.\n",
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Article"
              }
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the newsletter was connected to or created on Commune."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the profile last changed."
          }
        }
      },
      "ArticleStatus": {
        "type": "string",
        "title": "ArticleStatus",
        "description": "Where an article is in its life. A credential holding only `read`\npermissions ever sees `sent` and nothing else. An imported article is\nalways `sent`, since Commune sees it after the provider delivered it.\n",
        "enum": [
          "draft",
          "scheduled",
          "sending",
          "sent",
          "failed",
          "archived"
        ]
      },
      "ArticleStats": {
        "type": "object",
        "title": "ArticleStats",
        "description": "Engagement counts for an article, computed at read time. These are\nCommune side counts, not provider side email metrics: opens, clicks and\ndeliveries are not here.\n",
        "additionalProperties": false,
        "required": [
          "likes",
          "comments",
          "highlights"
        ],
        "properties": {
          "likes": {
            "type": "integer",
            "minimum": 0,
            "description": "How many people liked the article."
          },
          "comments": {
            "type": "integer",
            "minimum": 0,
            "description": "Replies in the article's chat thread. Commune has no separate\ncomments store: an article's discussion is a thread like any other,\nso this counts the undeleted replies hanging off it. `0` when the\narticle has no thread.\n"
          },
          "highlights": {
            "type": "integer",
            "minimum": 0,
            "description": "How many passages readers highlighted."
          }
        }
      },
      "Article": {
        "type": "object",
        "title": "Article",
        "description": "One article of a newsletter, without its body. Every collection of\narticles returns this shape. `GET /articles/{article}` returns\n`ArticleWithContent`, which is this plus `content`.\n",
        "required": [
          "object",
          "id",
          "short_id",
          "slug",
          "newsletter",
          "status",
          "is_imported",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "article",
            "description": "Always `article`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "short_id": {
            "type": "string",
            "description": "Eight character base62 identifier, unique across Commune. Safe in a\nURL and accepted anywhere `{article}` is.\n",
            "examples": [
              "k7Rm2xQp"
            ]
          },
          "slug": {
            "type": "string",
            "description": "URL segment under the newsletter, unique within it but not across\nCommune. The permalink is `/n/{handle}/a/{slug}`. Falls back to the\n`short_id` for an untitled article.\n"
          },
          "newsletter": {
            "description": "The newsletter this article belongs to. A `Ref` unless `newsletter` is\nnamed in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Subject line of the article. Null for an untitled draft."
          },
          "preview_text": {
            "type": [
              "string",
              "null"
            ],
            "description": "The short line email clients show after the subject, and what\nCommune uses as the excerpt on a card.\n"
          },
          "image_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Cover image. When the creator set none, Commune stamps the first\nimage in the body at send time, so this is usually populated for a\nsent article.\n"
          },
          "external_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "The article's canonical URL on the newsletter's own provider, for an\nimported article. Null for one written in Commune.\n"
          },
          "status": {
            "$ref": "#/components/schemas/ArticleStatus"
          },
          "is_imported": {
            "type": "boolean",
            "description": "`true` when the article came in from the newsletter's provider,\n`false` when it was written and sent in Commune.\n"
          },
          "posted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the article went out. An article dated in the future is not\nreturned by any read operation until that moment passes, so this is\nnever ahead of now in a response.\n"
          },
          "scheduled_for": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When a queued article may go out. Set while `status` is `scheduled`\nand null otherwise.\n\nThis is not `posted_at` and the difference matters: a queued article\nhas no publication date yet, which is why it stays invisible on\nevery reader surface until it really goes out. Commune dispatches\nin passes, so this is the moment from which the article may go rather\nthan the moment it will.\n"
          },
          "authors": {
            "type": "array",
            "description": "The byline, in order. Each entry is a `Ref` unless `authors` is\nnamed in `?expand=`. Empty when no Commune account is credited.\n",
            "items": {
              "anyOf": [
                {
                  "$ref": "#/components/schemas/Ref"
                },
                {
                  "$ref": "#/components/schemas/User"
                }
              ]
            }
          },
          "thread": {
            "description": "The chat thread this article opened, where its discussion lives.\n`null` when the newsletter does not open a thread per article. A `Ref`\nunless `thread` is named in `?expand=`.\n",
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Thread"
              }
            ]
          },
          "stats": {
            "$ref": "#/components/schemas/ArticleStats"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the row was created in Commune."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the article was last edited."
          }
        }
      },
      "ArticleWithContent": {
        "title": "ArticleWithContent",
        "description": "An article including its rendered body. Returned only by\n`GET /articles/{article}`. `content` cannot be requested on any list,\nincluding through `?fields=`.\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/Article"
          },
          {
            "type": "object",
            "required": [
              "content"
            ],
            "properties": {
              "content": {
                "type": "string",
                "description": "The body as HTML. For an article written in Commune this is the\nemail rendered for the web, with personalization placeholders\nresolved against an empty context so no raw merge tag is ever\nserved. For an imported article it is what the provider published.\n\nTreat it as untrusted markup from a third party and render it in\na sandboxed context.\n"
              },
              "content_markdown": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The same body as Markdown, present only when `content` is named\nin `?expand=`. It is what a model should read: the HTML is\nmostly markup it will not use, and one article body can fill a\ncontext window on its own.\n\nIt is a conversion of the body rather than of the HTML above.\nFor an article written in Commune it comes from the document the\nauthor wrote, so a code block keeps its language and a table\nthat declares a header becomes a Markdown table. For an\nimported article it comes from the provider's HTML. Either way\nthe words, the links, the images, the lists, the code and the\nquotes survive, and everything presentational does not.\n\n`null` means Commune holds no body it can convert faithfully.\nThat happens when the only body it stored is a rendered email,\nwhose words cannot be told apart from its layout. An empty\nstring means the article has no body, which is different.\n\nNo merge tag ever appears here, resolved or not.\n"
              }
            }
          }
        ],
        "unevaluatedProperties": false
      },
      "ArticleBodyMarkdown": {
        "type": "string",
        "title": "ArticleBodyMarkdown",
        "description": "An article's text, as Markdown. Create an article documents the\nMarkdown Commune accepts.\n",
        "examples": [
          "# What we learned in March\n\nThree things, and the **third** one surprised us.\n\n<Button href=\"https://example.com/read\" alignment=\"center\">Read the full piece</Button>\n\n<EmailOnly>\n[Unsubscribe]({{ unsubscribe_url }}) | {{ address }}\n</EmailOnly>\n"
        ]
      },
      "ThreadCreateRequest": {
        "type": "object",
        "title": "ThreadCreateRequest",
        "description": "A new thread in a newsletter's community.",
        "additionalProperties": false,
        "anyOf": [
          {
            "required": [
              "content"
            ]
          },
          {
            "required": [
              "media"
            ]
          }
        ],
        "properties": {
          "content": {
            "type": "string",
            "maxLength": 5000,
            "description": "The opening message, as Markdown. Surrounding whitespace is trimmed.\nOptional when `media` attaches something: a post needs text,\nattachments, or both.\n"
          },
          "visibility": {
            "type": "string",
            "enum": [
              "subscribers",
              "public"
            ],
            "default": "subscribers",
            "description": "`subscribers` keeps the thread inside the newsletter. `public` also\nputs it on Commune's global feed.\n"
          },
          "media": {
            "type": "array",
            "maxItems": 10,
            "description": "Images or videos to attach, by URL.",
            "items": {
              "$ref": "#/components/schemas/MediaCreateRequest"
            }
          }
        }
      },
      "MessageCreateRequest": {
        "type": "object",
        "title": "MessageCreateRequest",
        "description": "A reply in a thread.",
        "additionalProperties": false,
        "anyOf": [
          {
            "required": [
              "content"
            ]
          },
          {
            "required": [
              "media"
            ]
          }
        ],
        "properties": {
          "content": {
            "type": "string",
            "maxLength": 5000,
            "description": "The reply, as Markdown. Surrounding whitespace is trimmed.\nOptional when `media` attaches something: a post needs text,\nattachments, or both.\n"
          },
          "parent": {
            "type": "string",
            "description": "The reply this answers, by `id` or `short_id`. Leave it out to\nreply to the thread itself. It has to be a direct reply to this\nthread.\n"
          },
          "quoted": {
            "type": "string",
            "description": "The thread or a message in it to quote, by `id` or `short_id`.\n"
          },
          "media": {
            "type": "array",
            "maxItems": 10,
            "description": "Images or videos to attach, by URL.",
            "items": {
              "$ref": "#/components/schemas/MediaCreateRequest"
            }
          }
        }
      },
      "MediaCreateRequest": {
        "type": "object",
        "title": "MediaCreateRequest",
        "description": "An attachment, by URL. Commune does not copy it: the URL is shown as\ngiven, so it has to stay reachable.\n",
        "additionalProperties": false,
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 2000,
            "description": "Where the attachment is served from. `http` or `https`."
          },
          "type": {
            "type": "string",
            "enum": [
              "image",
              "video"
            ],
            "description": "What it is. Leave it out and Commune decides from the URL's\nextension: `.mp4`, `.webm` and `.mov` are videos, anything else an\nimage.\n"
          },
          "alt": {
            "type": "string",
            "maxLength": 500,
            "description": "A description for people who cannot see it."
          }
        }
      },
      "ArticleCreateRequest": {
        "type": "object",
        "title": "ArticleCreateRequest",
        "description": "A new article. Every property is optional: `{}` creates an empty\nuntitled draft.\n\nWhat is created is always a **draft**. There is no `status` here, no\n`posted_at` and no `scheduled_for`: an article reaches anybody through\nSchedule an article (`POST /articles/{article}/schedule`) or\nSend an article to the list (`POST /articles/{article}/send`), both of which run the gates\nthat keep a non-compliant article out of an inbox.\n",
        "additionalProperties": false,
        "properties": {
          "title": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 300,
            "description": "The subject line. Null or absent leaves the article untitled, which\nis legal: a draft is often started before it is named.\n"
          },
          "preview_text": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500,
            "description": "The short line email clients show after the subject.\n"
          },
          "image_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "maxLength": 2000,
            "description": "The cover image. Leave it out and Commune stamps the first image in\nthe body when the article is sent.\n"
          },
          "slug": {
            "type": "string",
            "maxLength": 60,
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
            "description": "The article's URL segment, unique within the newsletter. Lowercase\nletters, digits and single hyphens.\n\nLeave it out and Commune derives one from the title, adding a\nnumeric suffix if that one is taken. An untitled article falls back to\nits `short_id`. A slug you choose yourself is never renamed for you:\none that is already taken answers `422` rather than quietly becoming\nsomething else, because a permalink you did not choose is worse than\nan error you can act on.\n"
          },
          "content_markdown": {
            "$ref": "#/components/schemas/ArticleBodyMarkdown"
          }
        },
        "examples": [
          {
            "title": "What we learned in March",
            "preview_text": "The third one surprised us.",
            "content_markdown": "# What we learned in March\n\nThree things, and the **third** one surprised us.\n\n<EmailOnly>\n[Unsubscribe]({{ unsubscribe_url }}) | {{ address }}\n</EmailOnly>\n"
          }
        ]
      },
      "ArticleUpdateRequest": {
        "type": "object",
        "title": "ArticleUpdateRequest",
        "description": "The properties of an article to change. Send at least one; an empty object\nanswers `400` rather than doing nothing.\n\n**Absent and null are different.** A property you leave out is left\nalone. A property you send as `null` is cleared. An empty string is the\nsame as null, because a title that is present and empty reads as a\nmissing one everywhere it is shown.\n\n`slug` is the exception: it can be changed but not cleared, since every\narticle has to have one.\n\nThis never moves the article's state and never changes its byline. The\nstate moves through `POST /articles/{article}/schedule`,\n`/unschedule` and `/send`; the byline is not in this contract.\n",
        "additionalProperties": false,
        "minProperties": 1,
        "properties": {
          "title": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 300,
            "description": "The subject line. Null clears it."
          },
          "preview_text": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500,
            "description": "The short line email clients show after the subject. Null clears it.\n"
          },
          "image_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "maxLength": 2000,
            "description": "The cover image. Null clears it."
          },
          "slug": {
            "type": "string",
            "maxLength": 60,
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
            "description": "The article's URL segment. Changing it moves the article's permalink,\nand nothing forwards the old one, so change it knowing that.\n\nIt is not re-derived when you change the title. A slug that already\nbelongs to another article of this newsletter answers `422`.\n"
          },
          "content_markdown": {
            "$ref": "#/components/schemas/ArticleBodyMarkdown"
          }
        },
        "examples": [
          {
            "title": "What we learned in March, revisited"
          },
          {
            "image_url": null
          }
        ]
      },
      "ArticleImageRequest": {
        "type": "object",
        "title": "ArticleImageRequest",
        "description": "Exactly one of `source_url` or `content_type`.\n",
        "additionalProperties": false,
        "properties": {
          "source_url": {
            "type": "string",
            "format": "uri",
            "maxLength": 2000,
            "description": "A public http or https URL of the image for Commune to download\nand store.\n",
            "examples": [
              "https://example.com/chart.png"
            ]
          },
          "content_type": {
            "type": "string",
            "enum": [
              "image/jpeg",
              "image/png",
              "image/webp",
              "image/gif"
            ],
            "description": "The type of the file you will upload yourself. The answer carries\nthe upload URL.\n"
          }
        },
        "examples": [
          {
            "source_url": "https://example.com/chart.png"
          },
          {
            "content_type": "image/png"
          }
        ]
      },
      "ArticleImage": {
        "type": "object",
        "title": "ArticleImage",
        "description": "An image stored for an article, at a URL that does not change. The\narticle does not show it until its URL is in the body or `image_url`.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "url",
          "content_type",
          "size_bytes",
          "source_url",
          "upload"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "article_image",
            "description": "Always `article_image`."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Where the image is served. Use it in the article's Markdown or as\nits `image_url`. For an upload, it answers `404` until the file is\nuploaded.\n"
          },
          "content_type": {
            "type": "string",
            "enum": [
              "image/jpeg",
              "image/png",
              "image/webp",
              "image/gif"
            ],
            "description": "The image's type. For a download, read from the bytes themselves,\nwhatever the source said.\n"
          },
          "size_bytes": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "description": "How large the stored image is. Null for an upload, whose size is\nnot known until the file arrives.\n"
          },
          "source_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "The URL the image was downloaded from. Null for an upload."
          },
          "upload": {
            "description": "Where to send the file, when the request named a `content_type`.\nNull when the image was downloaded.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/ArticleImageUpload"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "ArticleImageUpload": {
        "type": "object",
        "title": "ArticleImageUpload",
        "description": "A one-time upload. Send the file's bytes as the request body, with\nthese headers, before it expires; for example\n`curl -X PUT -H \"Content-Type: image/png\" --data-binary @chart.png \"<url>\"`.\n",
        "additionalProperties": false,
        "required": [
          "url",
          "method",
          "headers",
          "max_bytes",
          "expires_at"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Where to send the file. It works once."
          },
          "method": {
            "type": "string",
            "const": "PUT",
            "description": "Always `PUT`."
          },
          "headers": {
            "type": "object",
            "description": "The headers the upload has to carry.",
            "additionalProperties": {
              "type": "string"
            }
          },
          "max_bytes": {
            "type": "integer",
            "description": "The largest file the upload accepts."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the upload URL stops working."
          }
        }
      },
      "TestSendRequest": {
        "type": "object",
        "title": "TestSendRequest",
        "description": "Where a test copy of an article goes. Empty, or no body at all, sends\nit to the person the credential belongs to.\n",
        "additionalProperties": false,
        "properties": {
          "to": {
            "type": "array",
            "description": "The addresses to send the test to. Leave it out to send it to\nyourself. A repeated address is sent to once. Five at most, because\nthis is a look before you send rather than a way to reach a group.\n",
            "minItems": 1,
            "maxItems": 5,
            "items": {
              "type": "string",
              "format": "email"
            },
            "examples": [
              [
                "editor@example.com",
                "proofreader@example.com"
              ]
            ]
          }
        }
      },
      "TestSend": {
        "type": "object",
        "title": "TestSend",
        "description": "What one test send attempted and what the sending provider accepted.\nNothing about the article changed and nobody on the list was touched.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "article",
          "recipients",
          "sent_to_owner",
          "sent",
          "failed"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "test_send",
            "description": "Always `test_send`."
          },
          "article": {
            "description": "The article a copy of which was sent. Always a `Ref`: a test changes\nnothing about the article, so there is nothing on it to read back.\n",
            "$ref": "#/components/schemas/Ref"
          },
          "recipients": {
            "type": "array",
            "description": "Where the test went, deduplicated and in the order given. These are\nthe addresses sent in the request; Commune adds none and reveals\nnone, so this is empty when the copy went to the credential's\nowner.\n",
            "items": {
              "type": "string",
              "format": "email"
            }
          },
          "sent_to_owner": {
            "type": "boolean",
            "description": "True when the request named no addresses and the copy went to the\nperson the credential belongs to, whose address is not echoed.\n"
          },
          "sent": {
            "type": "integer",
            "minimum": 0,
            "description": "How many of them the sending provider accepted."
          },
          "failed": {
            "type": "integer",
            "minimum": 0,
            "description": "How many it refused. `sent` plus `failed` is always the number of\n`recipients`, or one when `sent_to_owner` is true. A test where\nevery address failed answers `502` rather than this shape.\n"
          }
        }
      },
      "ScheduleRequest": {
        "type": "object",
        "title": "ScheduleRequest",
        "description": "When an article should go out.\n",
        "additionalProperties": false,
        "required": [
          "scheduled_for"
        ],
        "properties": {
          "scheduled_for": {
            "type": "string",
            "format": "date-time",
            "description": "The moment from which the article may go out. Has to be in the\nfuture. Commune dispatches queued articles in passes, so this is the\nearliest it will go rather than the exact moment it does.\n",
            "examples": [
              "2026-10-01T09:00:00Z"
            ]
          },
          "acknowledge_broken_images": {
            "type": "boolean",
            "default": false,
            "description": "Schedule even though an image in the body will not load. See the\nsame field on the send operation for what this acknowledges and how\nlong it lasts.\n"
          }
        }
      },
      "SendRequest": {
        "type": "object",
        "title": "SendRequest",
        "description": "Options for a send. Every field is optional, and sending no body at all\nis the ordinary case.\n",
        "additionalProperties": false,
        "properties": {
          "acknowledge_broken_images": {
            "type": "boolean",
            "default": false,
            "description": "Send even though an image in the body will not load for a reader.\n\nWithout this, an image Commune could definitively not fetch refuses\nthe send. With it, the send proceeds and the acknowledgement is\nrecorded against the article as it currently reads, so the dispatch\nthat happens a few minutes later honours the same decision. Any\nedit to the body clears it, which keeps it scoped to the images\nthat were actually looked at.\n\nIt does not suppress anything else. An unreadable image is still\nreported on the article, and the footer gate is not escapable at all.\n"
          }
        }
      },
      "DnsRecord": {
        "type": "object",
        "title": "DnsRecord",
        "description": "A record the creator has to publish in their own DNS before Commune can\nsend from an address or serve a domain. These are public by nature: they\nend up in a zone anyone can query.\n",
        "additionalProperties": false,
        "required": [
          "type",
          "name",
          "value"
        ],
        "properties": {
          "type": {
            "type": "string",
            "description": "The record type.",
            "examples": [
              "CNAME"
            ]
          },
          "name": {
            "type": "string",
            "description": "The host the record goes on.",
            "examples": [
              "mail._domainkey.example.org"
            ]
          },
          "value": {
            "type": "string",
            "description": "What the record must resolve to."
          },
          "priority": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Priority, for a record type that takes one. Null otherwise."
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "description": "What Commune last observed for this record, when it checked it\nindividually. Null when only the address as a whole was checked.\n"
          }
        }
      },
      "SenderKind": {
        "type": "string",
        "title": "SenderKind",
        "description": "`commune` is an address Commune provisioned on a domain it owns, which\nworks without the creator touching DNS. `custom` is an address on the\ncreator's own domain, which does not work until they publish the\nrecords.\n",
        "enum": [
          "commune",
          "custom"
        ]
      },
      "SenderVerificationStatus": {
        "type": "string",
        "title": "SenderVerificationStatus",
        "description": "How far along the address is. Only `verified` can send. `provisioning`\nmeans Commune is still setting it up and the creator has nothing to do\nyet.\n",
        "enum": [
          "provisioning",
          "pending",
          "verified",
          "failed"
        ]
      },
      "Sender": {
        "type": "object",
        "title": "Sender",
        "description": "An address a newsletter sends from. Nothing about the underlying email\ninfrastructure is exposed: the provider's own identifiers for the domain\nstay server side, because they are an implementation detail Commune\nreserves the right to change.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "kind",
          "from_email",
          "verification_status",
          "is_default",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "sender",
            "description": "Always `sender`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "newsletter": {
            "description": "The newsletter that sends from this address. A `Ref` unless\n`newsletter` is named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "kind": {
            "$ref": "#/components/schemas/SenderKind"
          },
          "from_email": {
            "type": "string",
            "format": "email",
            "description": "The full address articles are sent from."
          },
          "from_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "The display name shown beside the address in an inbox."
          },
          "reply_to_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Where replies go, when the creator wants them somewhere other than\n`from_email`.\n"
          },
          "domain": {
            "type": "string",
            "description": "The domain part of the address."
          },
          "local_part": {
            "type": "string",
            "description": "The part before the at sign."
          },
          "verification_status": {
            "$ref": "#/components/schemas/SenderVerificationStatus"
          },
          "verification_records": {
            "type": "array",
            "description": "The DNS records the creator must publish. Empty for a `commune`\naddress, where Commune owns the domain and has already done it.\n",
            "items": {
              "$ref": "#/components/schemas/DnsRecord"
            }
          },
          "verification_error": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why verification last failed, in a form the creator can act on. Null\nwhen it has not failed.\n"
          },
          "is_default": {
            "type": "boolean",
            "description": "Whether this is the address the newsletter sends from unless told\notherwise. Exactly one address per newsletter has this.\n"
          },
          "verified_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the address was first verified. Null if it never was."
          },
          "last_checked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When Commune last looked at the DNS. Verification is re checked on a\nschedule, so this moves without the creator doing anything.\n"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the address was added."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the row last changed."
          }
        }
      },
      "DomainVerificationStatus": {
        "type": "string",
        "title": "DomainVerificationStatus",
        "description": "Whether ownership of the domain was proved and a certificate issued.\n`active` does not by itself mean the site is reachable, see\n`routing_ok`.\n",
        "enum": [
          "pending",
          "active",
          "failed"
        ]
      },
      "Domain": {
        "type": "object",
        "title": "Domain",
        "description": "A creator's own domain serving their Commune site. The certificate\nprovider's internal identifier for it is not exposed.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "domain",
          "verification_status",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "domain",
            "description": "Always `domain`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "newsletter": {
            "description": "The newsletter the domain serves. A `Ref` unless `newsletter` is\nnamed in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "domain": {
            "type": "string",
            "description": "The hostname, unique across Commune.",
            "examples": [
              "blog.example.org"
            ]
          },
          "verification_status": {
            "$ref": "#/components/schemas/DomainVerificationStatus"
          },
          "ssl_status": {
            "type": [
              "string",
              "null"
            ],
            "description": "Where the certificate stands, as reported by the provider that\nissues it. Free text rather than an enum, because the vocabulary is\nthe provider's and Commune passes it through.\n"
          },
          "dns_target": {
            "type": [
              "string",
              "null"
            ],
            "description": "What the creator must point the hostname at."
          },
          "verification_records": {
            "type": "array",
            "description": "The records the creator must publish to prove ownership.",
            "items": {
              "$ref": "#/components/schemas/DnsRecord"
            }
          },
          "verification_error": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why verification last failed. Null when it has not failed."
          },
          "routing_ok": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Whether the domain actually resolves to Commune, from an end to end\nrequest rather than from certificate state. Null before the first\nprobe. This is the field that catches a domain whose certificate is\nfine but whose DNS points somewhere else.\n"
          },
          "routing_checked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When that probe last ran."
          },
          "verified_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When ownership was first proved. Null if it never was."
          },
          "last_checked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When Commune last reconciled the domain with its provider."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the domain was added."
          }
        }
      },
      "Destination": {
        "type": "object",
        "title": "Destination",
        "description": "One place a newsletter's published events are delivered to.\n\n**Nothing a destination authenticates with is on this shape**, and\nneither is its full configuration, which for an HTTPS endpoint can\ninclude request headers holding an API key. `target` is what this shape\ncarries in their place, and the portal is where the person who set the\ndestination up can read the rest.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "type",
          "topics",
          "enabled",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "destination",
            "description": "Always `destination`."
          },
          "id": {
            "type": "string",
            "description": "The delivery service's identifier for this destination. Opaque, and\nnot a UUID: it is minted on the other side of the portal and is the\nvalue that identifies the same destination there.\n"
          },
          "newsletter": {
            "description": "The newsletter whose events go here. A `Ref` unless `newsletter` is\nnamed in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "type": {
            "type": "string",
            "description": "What kind of target this is. `webhook` is an HTTPS endpoint and is\nthe common case; the rest are queues, streams and object stores, for\na consumer that would rather not run a web server.\n\nFree text rather than an enumeration, because the vocabulary belongs\nto the delivery service and grows there. Treat an unrecognised value\nas a destination this client does not know how to describe, never as\nan error.\n",
            "examples": [
              "webhook"
            ]
          },
          "target": {
            "type": [
              "string",
              "null"
            ],
            "description": "A short, human readable summary of where this destination points:\nthe host of an endpoint, or the name of a queue, stream or bucket.\nEnough to tell two destinations apart in a list, and never a full\nURL, because a URL can carry a token in its query string.\n",
            "examples": [
              "hooks.example.org"
            ]
          },
          "topics": {
            "type": "array",
            "description": "The event types delivered here, by name, matching the keys of the\n`webhooks` block of this document. A single entry of `*` means every\ntopic, including ones added after the destination was created.\n",
            "items": {
              "type": "string"
            },
            "examples": [
              [
                "article.published",
                "subscriber.created"
              ],
              [
                "*"
              ]
            ]
          },
          "enabled": {
            "type": "boolean",
            "description": "Whether this destination is receiving events. The same fact as\n`disabled_at` being null, stated as the boolean a caller actually\nwants, and derived from it so the two cannot disagree.\n"
          },
          "disabled_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When delivery to this destination was switched off. Null while it is\nenabled. A disabled destination is skipped rather than queued, so\nevents published while it is off are not delivered when it comes\nback on.\n"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the destination was added."
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When it was last changed. Null if it never has been."
          }
        }
      },
      "DeliveryAttemptStatus": {
        "type": "string",
        "title": "DeliveryAttemptStatus",
        "description": "How one attempt ended. `succeeded` is a 2xx from the destination.\n`failed` is anything else, including no answer at all, and is not\nfinal: the delivery service retries on its own.\n",
        "enum": [
          "succeeded",
          "failed"
        ]
      },
      "DeliveryAttempt": {
        "type": "object",
        "title": "DeliveryAttempt",
        "description": "One handover of one event to one destination, and what came of it.\n\nA record of something that happened rather than a thing with a state:\nit never changes after it is written, and a retry is a second\n`DeliveryAttempt` with a higher `attempt` rather than an edit to this\none.\n\n**Two fields a reader might expect are not here.** The body your\nendpoint answered with is never returned, since a refusing endpoint\nroutinely writes the request back into its own response, credentials\nincluded. Neither is the event's payload: several topics carry a\nsubscriber's email address, and returning it here would make every read\nof this log a read of audience data. `event_id` names the event,\n`event_type` says which\ntopic it was, and `response_status` says what the endpoint answered.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "destination",
          "event_id",
          "event_type",
          "status",
          "response_status",
          "failure",
          "attempt",
          "manual",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "delivery_attempt",
            "description": "Always `delivery_attempt`."
          },
          "id": {
            "type": "string",
            "description": "The delivery service's identifier for this attempt. Opaque, and not\na UUID: it is minted on the other side of the handover.\n"
          },
          "newsletter": {
            "description": "The newsletter whose event this was. A `Ref` unless `newsletter` is\nnamed in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "destination": {
            "allOf": [
              {
                "$ref": "#/components/schemas/Ref"
              }
            ],
            "description": "Where this was delivered. Always a `Ref`, whose `id` matches a row\nfrom `GET /newsletters/{newsletter}/destinations`. A destination\ndeleted since the attempt was made still appears here, because the\nattempt happened; it will not be in that list any more.\n"
          },
          "destination_type": {
            "type": "string",
            "description": "What kind of target it was, as the delivery service named it at the\ntime. Free text for the same reason `Destination.type` is: the\nvocabulary belongs to the delivery service and grows there.\n",
            "examples": [
              "webhook"
            ]
          },
          "event_id": {
            "type": "string",
            "description": "The event that was being delivered, by the `id` on its envelope.\nThe same string the consumer receives in the `Commune-Event-Id`\nheader, which makes it the one identifier both sides share and the\nthing worth logging on yours.\n"
          },
          "event_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "The topic, matching the keys of the `webhooks` block of this\ndocument. Null only if the delivery service no longer holds the\nevent this attempt belonged to.\n",
            "examples": [
              "article.published"
            ]
          },
          "status": {
            "allOf": [
              {
                "$ref": "#/components/schemas/DeliveryAttemptStatus"
              }
            ],
            "description": "How this attempt ended."
          },
          "response_status": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The HTTP status the destination answered with. Null when it did not\nanswer at all, in which case `failure` says why.\n",
            "examples": [
              200,
              500
            ]
          },
          "failure": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why there was no answer, when there was none: `timeout` is the\ncommon one. Null whenever `response_status` is set, and the two are\nnever both set or both null. Free text, so treat an unrecognised\nvalue as a reason this client does not know how to describe.\n",
            "examples": [
              "timeout"
            ]
          },
          "attempt": {
            "type": "integer",
            "minimum": 1,
            "description": "1 on the first delivery of this event to this destination, and one\nhigher on each retry of it. The number the\n`Commune-Delivery-Attempt` header would carry if it were sent.\n"
          },
          "manual": {
            "type": "boolean",
            "description": "Whether somebody asked for this attempt rather than the delivery\nservice making it on its own. True for one made by\n`POST /delivery-attempts/{attempt}/replay` or by the retry button in\nthe portal, and false for a first delivery or an automatic retry.\n"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the attempt was made."
          }
        }
      },
      "DeliveryReplay": {
        "type": "object",
        "title": "DeliveryReplay",
        "description": "The acknowledgement that a replay was accepted.\n\nNot an attempt: the attempt this produces does not exist yet when the\nresponse is written. What it carries is enough to find that attempt once\nit appears, by reading the delivery log filtered to the same event.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "attempt",
          "event_id",
          "destination"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "delivery_replay",
            "description": "Always `delivery_replay`."
          },
          "attempt": {
            "allOf": [
              {
                "$ref": "#/components/schemas/Ref"
              }
            ],
            "description": "The attempt that was replayed, not the one this makes. Always a\n`Ref`, and it is unchanged: it is a record of a delivery that\nalready happened.\n"
          },
          "event_id": {
            "type": "string",
            "description": "The event being delivered again. Filter the delivery attempt log by\nit to find the new attempt once the delivery service has made it.\n"
          },
          "destination": {
            "allOf": [
              {
                "$ref": "#/components/schemas/Ref"
              }
            ],
            "description": "Where it is being delivered again. Always a `Ref`."
          }
        }
      },
      "PortalSession": {
        "type": "object",
        "title": "PortalSession",
        "description": "A link into the delivery portal, and the moment it stops working.\n\nNot a resource: it has no identifier, nothing addresses it, and it\ncannot be fetched again. It is a credential that was minted for one\nperson to follow once, and the only copy of it is the one in this\nresponse.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "url",
          "expires_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "portal_session",
            "description": "Always `portal_session`."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Where to send the creator. The token in the query string is a bearer\ncredential scoped to this newsletter, so treat the whole URL as one:\nredirect, do not store, do not log, and mint another when another is\nneeded.\n"
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the credential in `url` stops being accepted. Advisory, and\nnull when the delivery service did not say: it is there so a caller\ncan decide whether a link it is holding is still worth following,\nand never something to schedule against.\n"
          }
        }
      },
      "MeteredFeature": {
        "type": "string",
        "title": "MeteredFeature",
        "description": "Something in this API that can be put behind a plan. There are two:\nreads are free, with one exception.\n\n`writes` covers every operation that changes something. `insights`\ncovers the engagement and metrics reads, which are the only reads\nCommune reserves the right to meter.\n\nNew members may be added in a minor version. Treat one you do not\nrecognise as a capability that does not concern you, the same way an\nunrecognised `ErrorCode` or rate limit budget name is treated.\n",
        "enum": [
          "insights",
          "writes"
        ]
      },
      "Entitlement": {
        "type": "object",
        "title": "Entitlement",
        "description": "One capability, and whether this newsletter's key has it.\n\n`granted` answers \"will my next call work\". `included` answers \"does\nthis plan carry the capability at all\". They differ only while Commune\nis not charging for the feature, which is what `metered` reports.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "feature",
          "granted",
          "included",
          "metered",
          "plans",
          "reason"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "entitlement",
            "description": "Always `entitlement`."
          },
          "feature": {
            "$ref": "#/components/schemas/MeteredFeature"
          },
          "granted": {
            "type": "boolean",
            "description": "Whether a call needing this capability would be allowed right now.\nThis is the field to branch on. `false` means the same call answers\n`402`, with `reason` as its message.\n"
          },
          "included": {
            "type": "boolean",
            "description": "Whether the newsletter's plan carries the capability, whether or not\nCommune is charging for it yet. `granted` without `included` is a\ncapability that works today and would stop working the day metering\nis switched on, which is the case worth warning a creator about.\n"
          },
          "metered": {
            "type": "boolean",
            "description": "Whether Commune charges for this capability right now.\n"
          },
          "plans": {
            "type": "array",
            "description": "The plans that include this capability. The same list a `402`\ncarries in `allowed_values`.\n",
            "items": {
              "type": "string"
            }
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why a call would be refused, word for word as the `402` would say\nit: what the capability is, the plan this newsletter is on, the\nplans that would work, and what stays free. Null when `granted`.\n"
          }
        }
      },
      "Entitlements": {
        "type": "object",
        "title": "Entitlements",
        "description": "What this newsletter's plan includes, and what a key bound to it may\ncall. Read it before the first write of a reconciliation rather than\nafter the `402` that stops one halfway.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "newsletter",
          "plan",
          "status",
          "api_access"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "entitlements",
            "description": "Always `entitlements`."
          },
          "newsletter": {
            "description": "The newsletter this describes, which is always the one the key is\nbound to. A `Ref` unless `newsletter` is named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "plan": {
            "type": [
              "string",
              "null"
            ],
            "description": "The Commune plan this newsletter is on, or null when it has none on\nrecord. Most newsletters connected to an outside provider have\nnone, which is not an error and is why this is nullable rather than\nabsent.\n\nNot an enum. Plan names are a commercial decision that moves faster\nthan a contract version, and a client that switched on this value\nwould break on the next one Commune offers. What to branch on is\n`api_access`, which answers the question a plan name is a proxy for.\n"
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "description": "The state of the newsletter's subscription, or null alongside a null\n`plan`. Also not an enum, for the same reason. A subscription whose\npayment is merely late still carries every capability its plan does,\nso do not infer a refusal from this field: read `granted`.\n"
          },
          "api_access": {
            "type": "array",
            "description": "One entry per capability this API can meter, so the set is the\n`MeteredFeature` enum and nothing else. It is a list rather than a\nmap keyed by feature so that a client that does not recognise a\nmember can skip it without the shape changing.\n",
            "items": {
              "$ref": "#/components/schemas/Entitlement"
            }
          }
        }
      },
      "PermissionLevel": {
        "type": "string",
        "title": "PermissionLevel",
        "description": "How much of one family a credential holds on one newsletter.\n\n`none` is no access at all. `read` reads that part of the newsletter as\nit has been published. `write` adds changing it, and with it the\nnewsletter as it is being made: a credential that can change something\nabout a newsletter also sees its drafts, its scheduled articles and the\nthreads addressed to one segment, because those are unpublished rather\nthan secret and the people who may see them are the people who may\nchange them.\n\n`write` implies `read` **within its own family and nowhere else**.\nThere is no hierarchy across families: `content: write` is no claim at\nall on `audience`.\n",
        "enum": [
          "none",
          "read",
          "write"
        ]
      },
      "NewsletterPermissions": {
        "type": "object",
        "title": "NewsletterPermissions",
        "description": "What a credential may do on one newsletter, family by family.\n\nEvery family is always present, so a client never has to decide what an\nabsent one means. What a credential holds is granted per newsletter, so\nthe same credential can carry different permissions on each of the\nnewsletters it reaches.\n\nThese are what was **granted**. What a credential can actually do is\nthat intersected with what the person it belongs to can do on the\nnewsletter at the moment of the request, which moves when a team does:\na grant is a ceiling and never an independent authority. Demote its\nholder from admin to editor and the credential narrows on its next\nrequest, with nothing to revoke and nothing to wait for. A `403` says\nwhich of the two refused.\n",
        "additionalProperties": false,
        "required": [
          "content",
          "audience",
          "sending",
          "insights",
          "settings",
          "webhooks"
        ],
        "properties": {
          "content": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PermissionLevel"
              }
            ],
            "description": "Articles, the passages readers marked in them, threads and messages."
          },
          "audience": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PermissionLevel"
              }
            ],
            "description": "Subscribers, the segments they are in, and the community roster. The one family whose rows carry email addresses."
          },
          "sending": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PermissionLevel"
              }
            ],
            "description": "Sending an article, the addresses it goes out from, the domains behind them, and the log of what was delivered where."
          },
          "insights": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PermissionLevel"
              }
            ],
            "description": "Engagement scores, events and the computed metrics over them."
          },
          "settings": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PermissionLevel"
              }
            ],
            "description": "The newsletter's configuration, its team and its credentials. Reading the credential list is the first half of turning one off, so the credential operations need `write` here rather than `read`."
          },
          "webhooks": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PermissionLevel"
              }
            ],
            "description": "Event destinations and the portal session that edits them."
          }
        }
      },
      "ApiKey": {
        "type": "object",
        "title": "ApiKey",
        "description": "A credential, as it can be described without its secret.\n\nThe secret is not here and no parameter brings it back. Commune stores a\ndigest of it and shows the plaintext once, to the person who minted it;\nafter that only `key_prefix` survives. Use this object to recognise a\nkey, see whether anything is still calling with it, and turn it off.\n\n**A key belongs to a person, not to a newsletter.** It carries a list of\nthe newsletters that person granted it, each with its own permissions,\nand what it can actually reach is that list intersected with what its\nowner can do on each of them at the moment of the request. So a key\nloses a newsletter the day its owner leaves that team, with nothing to\nrevoke, and reaches nothing once the account behind it is gone.\n\nA key can also be granted **every newsletter its owner runs**, now and\nin future, rather than a named list. Such a key appears on the list of\neach newsletter it reaches.\n\nThis object describes the key **as it stands on one newsletter**:\n`newsletter` is the one the grant being read belongs to, and\n`permissions` is what that grant carries. Reading the same key through\nanother newsletter's list reports that newsletter and its own\npermissions, which may be different.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "name",
          "key_prefix",
          "permissions",
          "pinned_version",
          "live",
          "revoked",
          "self",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "api_key",
            "description": "Always `api_key`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier. This is what addresses the key in a path; the\nsecret never appears in a URL and never will.\n"
          },
          "newsletter": {
            "description": "The newsletter this projection describes: the one whose grant\n`permissions` was read from. A key may hold several, so this is not\n\"the key's newsletter\" but the one it is being listed under. A `Ref`\nunless `newsletter` is named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "name": {
            "type": "string",
            "description": "What the creator called it when they minted it. Not unique: two\nkeys called `staging` are a creator's problem and not an error.\n"
          },
          "key_prefix": {
            "type": "string",
            "description": "The leading fifteen characters of the secret, which is all of it\nthat Commune keeps. Enough to recognise which key an integration is\nconfigured with, and short enough that it is not itself usable.\n",
            "examples": [
              "cmn_sk_7Qd2xLpA"
            ]
          },
          "permissions": {
            "allOf": [
              {
                "$ref": "#/components/schemas/NewsletterPermissions"
              }
            ],
            "description": "What this key was granted **on the newsletter above**. Six families,\neach `none`, `read` or `write`.\n\nA key can be granted more than one newsletter and can carry\ndifferent permissions on each, so this is the grant for the\nnewsletter this row is being served under and not a property of the\nkey on its own. An operation this key does not hold the family for\nanswers `403` naming the family and the level it needed.\n"
          },
          "pinned_version": {
            "type": "string",
            "format": "date",
            "description": "The contract version a request made with this key resolves to when\nit sends no `Commune-Version` header. Stamped when the key was\nminted, so a newer contract shipping does not move an existing\nintegration.\n",
            "examples": [
              "2026-08-26"
            ]
          },
          "live": {
            "type": "boolean",
            "description": "Whether a request made with this key right now would be\nauthenticated. False once it has been revoked, and false once it\nhas expired. Computed against Commune's own clock with the same\ntest the authentication path applies, so it is a more reliable\nanswer than comparing `expires_at` to a client's clock.\n"
          },
          "revoked": {
            "type": "boolean",
            "description": "Whether somebody turned this key off. A revoked key never becomes\nlive again: nothing in this API can revive one.\n"
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When it was turned off, or null while it is not. A second\nrevocation does not move it.\n"
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the key stops working on its own, or null for one that never\ndoes. Expiry and revocation are separate: an expired key has not\nbeen revoked and reports `revoked` false.\n"
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "The last time a request was authenticated with this key, or null if\nnone ever has been. Written at most once a minute, so it is\naccurate to the minute rather than to the request, which is the\nresolution the question behind it needs: is anything still calling\nwith this, and can it be revoked.\n"
          },
          "self": {
            "type": "boolean",
            "description": "True for the one key the current request was made with, and false\nfor every other. A caller holds a secret rather than an id, so this\nis the only way it can tell which of these rows is itself, which is\nwhat it needs before revoking any of them. False on every row for a\nrequest made with an OAuth access token, since no key is that\ncredential.\n"
          },
          "created_by": {
            "description": "The team member who minted it, or null if that account has since\nbeen removed. The key belongs to the newsletter rather than to the\nperson, so it keeps working either way. A `Ref` unless `created_by`\nis named in `?expand=`.\n",
            "anyOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/User"
              }
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the key was minted."
          }
        }
      },
      "SearchResultType": {
        "type": "string",
        "title": "SearchResultType",
        "description": "What a search result points at. There is no post or comment kind: a\ndiscussion result is a `thread` or a `message`.\n",
        "enum": [
          "newsletter",
          "article",
          "thread",
          "message"
        ]
      },
      "SearchResult": {
        "type": "object",
        "title": "SearchResult",
        "description": "One hit. It always carries enough to render a row without a second\nrequest, and points at the full resource through `resource`.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "type",
          "resource"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "search_result",
            "description": "Always `search_result`."
          },
          "type": {
            "$ref": "#/components/schemas/SearchResultType"
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "description": "The heading for the row: a newsletter's name, an article's title, a\nperson's display name. Null for a chat hit, which has no title.\n"
          },
          "snippet": {
            "type": [
              "string",
              "null"
            ],
            "description": "A fragment of the matching text with the query terms in it, as plain\ntext. Null when the match was on the title alone.\n"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Where the hit lives on the web."
          },
          "score": {
            "type": "number",
            "minimum": 0,
            "description": "How well the hit matched, higher being better. Results come back\nordered by it, so use it to compare hits within one response and to\ndraw a relevance cutoff. It is not comparable across queries or\nacross contract versions, and the scale is undefined.\n"
          },
          "resource": {
            "description": "The object itself. A `Ref` unless `resource` is named in `?expand=`,\nin which case it is the full object of the kind named by `type`.\n",
            "anyOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              },
              {
                "$ref": "#/components/schemas/Article"
              },
              {
                "$ref": "#/components/schemas/Thread"
              },
              {
                "$ref": "#/components/schemas/Message"
              }
            ]
          }
        }
      },
      "Highlight": {
        "type": "object",
        "title": "Highlight",
        "description": "A passage of an article a reader marked. Highlights are the anchor for\nan inline comment, which is why one can carry a link to the message it\nstarted.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "article",
          "quote",
          "start_offset",
          "end_offset",
          "owner_key",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "highlight",
            "description": "Always `highlight`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "article": {
            "description": "The article the passage is in. A `Ref` unless `article` is named in\n`?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Article"
              }
            ]
          },
          "quote": {
            "type": "string",
            "maxLength": 5000,
            "description": "The marked text itself, as plain text."
          },
          "prefix": {
            "type": "string",
            "description": "Up to two hundred characters of the text immediately before the\nquote. Together with `suffix` this re anchors the highlight when the\nbody changed and the offsets no longer line up.\n"
          },
          "suffix": {
            "type": "string",
            "description": "Up to two hundred characters of the text immediately after the quote."
          },
          "start_offset": {
            "type": "integer",
            "minimum": 0,
            "description": "Where the passage starts, as a character offset into the article's\nplain text. Always less than `end_offset`.\n"
          },
          "end_offset": {
            "type": "integer",
            "minimum": 1,
            "description": "Where the passage ends, as a character offset into the plain text."
          },
          "owner_key": {
            "type": "string",
            "description": "An opaque, stable per highlighter value, scoped to this one article.\nGroup by it to tell one reader's marks apart from another's, and\ncount distinct values for a distinct highlighter count. It cannot be\nresolved to a person and does not correlate across articles: Commune\ndoes not attribute a highlight to a named reader.\n",
            "examples": [
              "4f2a9c1e7b3d6a05"
            ]
          },
          "message": {
            "description": "The chat message the reader wrote from this passage, when they wrote\none. `null` otherwise. A `Ref` unless `message` is named in\n`?expand=`.\n",
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Message"
              }
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the passage was marked."
          }
        }
      },
      "Tag": {
        "type": "object",
        "title": "Tag",
        "description": "A segment of a newsletter's audience. A tag is what makes an article\naudience scoped: sending to a tag stamps the article, and from then on\nonly holders of that tag and the newsletter's team can read it.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "name",
          "retired",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "tag",
            "description": "Always `tag`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "newsletter": {
            "description": "The newsletter the tag belongs to. A tag never spans newsletters. A\n`Ref` unless `newsletter` is named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "name": {
            "type": "string",
            "description": "What the creator calls the segment. Unique among the newsletter's\nlive tags, and freed for reuse once a tag is retired.\n"
          },
          "known_subscriber_count": {
            "type": "integer",
            "minimum": 0,
            "description": "How many currently subscribed people Commune knows of who hold this\ntag. Counted at read time from Commune's own record of the\naudience, which for a newsletter connected to an outside provider\nis a partial cache of that provider's list. Named `known_` for that\nreason: it is a floor, never the segment's true size, and it must\nnot be presented as one.\n"
          },
          "retired": {
            "type": "boolean",
            "description": "`true` when the creator removed the tag but an already sent article is\nstill addressed to it. Retired tags keep their assignments, because\nthe audience of a sent article does not change retroactively. Excluded\nfrom the tag list unless `include_retired=true`.\n"
          },
          "retired_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the tag was retired. Null while it is live."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the tag was created."
          }
        }
      },
      "TagName": {
        "type": "string",
        "title": "TagName",
        "minLength": 1,
        "maxLength": 60,
        "description": "What the creator calls the segment. Leading and trailing spaces are\ntrimmed, and what is left has to be between 1 and 60 characters and\nunique among the newsletter's live tags.\n"
      },
      "TagCreateRequest": {
        "type": "object",
        "title": "TagCreateRequest",
        "description": "A new, empty segment of the newsletter's audience.",
        "additionalProperties": false,
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "$ref": "#/components/schemas/TagName"
          }
        },
        "examples": [
          {
            "name": "Founding member"
          }
        ]
      },
      "TagUpdateRequest": {
        "type": "object",
        "title": "TagUpdateRequest",
        "description": "A tag's new name. The only thing about a tag that can be changed: who\nholds it is moved by applying it to subscribers and taking it off them,\nand whether it is retired is decided by deleting it.\n",
        "additionalProperties": false,
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "$ref": "#/components/schemas/TagName"
          }
        },
        "examples": [
          {
            "name": "Founding members"
          }
        ]
      },
      "TagDeletion": {
        "type": "object",
        "title": "TagDeletion",
        "description": "What became of a tag that was deleted, which is one of two things.\n\nNot a tag: a tag that was removed outright no longer exists, and one\nthat was retired instead is still live enough to decide who may read\nthe articles it was addressed to. A single shape that could mean either\nwould let a caller read a retirement as an ending, which it is not.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "outcome",
          "tag"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "tag_deletion",
            "description": "Always `tag_deletion`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "The tag that was deleted. Still resolvable through\n`GET /tags/{tag}` when `outcome` is `retired`, and a `404` when it\nis `deleted`.\n"
          },
          "outcome": {
            "type": "string",
            "enum": [
              "deleted",
              "retired"
            ],
            "description": "`deleted` means no article was ever addressed to the tag, so it was\nremoved and its assignments went with it.\n\n`retired` means an article was, so the tag was kept. Everyone\nholding it goes on holding it and goes on being able to read every\narticle it was addressed to; what stops is the tag being offered\nand being possible to add a holder to. A retirement revokes\nnobody's access. To take access away, take the tag off the people\nholding it one at a time.\n"
          },
          "tag": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Tag"
              },
              {
                "type": "null"
              }
            ],
            "description": "The retired tag, as it now stands, and null when the tag was\nremoved outright. Its `known_subscriber_count` is the count the tag\nhad before this call rather than zero, which is the plainest\nevidence that a retirement kept its holders.\n"
          }
        }
      },
      "TagAssignmentRequest": {
        "type": "object",
        "title": "TagAssignmentRequest",
        "description": "The subscribers to put in a segment. The segment is the tag in the\npath.\n",
        "additionalProperties": false,
        "required": [
          "subscribers"
        ],
        "properties": {
          "subscribers": {
            "type": "array",
            "minItems": 1,
            "maxItems": 500,
            "description": "Subscriber ids, the `id` of each `Subscriber`: never email\naddresses. 1 to 500 of them, counted as sent. An id that appears\nmore than once is applied and reported once.\n",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          }
        },
        "examples": [
          {
            "subscribers": [
              "33445566-7788-4990-a1b2-c3d4e5f60718",
              "44556677-8899-4aa1-b2c3-d4e5f6071829"
            ]
          }
        ]
      },
      "TagAssignment": {
        "type": "object",
        "title": "TagAssignment",
        "description": "What applying a tag to a list of subscribers did, subscriber by\nsubscriber.\n\n`tagged`, `already_tagged` and `not_found` are disjoint, and together\nthey hold every id the request named, once each, in the order the\nrequest named them. Only `tagged` changed anything.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "tag",
          "tagged",
          "already_tagged",
          "not_found"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "tag_assignment",
            "description": "Always `tag_assignment`."
          },
          "tag": {
            "$ref": "#/components/schemas/Tag",
            "description": "The tag, as it now stands. Its `known_subscriber_count` is read\nafter the change, so it includes everyone in `tagged` who is\ncurrently subscribed.\n"
          },
          "tagged": {
            "type": "array",
            "description": "Subscribers who hold the tag now and did not before. One\n`subscriber.tagged` event was published for each.\n",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "already_tagged": {
            "type": "array",
            "description": "Subscribers who already held the tag. Nothing changed for them and\nnothing was published.\n",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "not_found": {
            "type": "array",
            "description": "Ids that are not subscribers of the tag's newsletter, whether they\nbelong to another newsletter or to nobody. Nothing was written for\nthem. Retrying will give the same answer; check where the ids came\nfrom instead.\n",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          }
        }
      },
      "MemberRole": {
        "type": "string",
        "title": "MemberRole",
        "description": "What someone may do on behalf of a newsletter. `owner` is not a stored\nmembership: it is the account the newsletter belongs to, surfaced here\nas a role so the team reads as one list.\n",
        "enum": [
          "owner",
          "admin",
          "editor",
          "guest"
        ]
      },
      "Member": {
        "type": "object",
        "title": "Member",
        "description": "A person's place on a newsletter's team.",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "user",
          "role"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "member",
            "description": "Always `member`."
          },
          "id": {
            "type": "string",
            "description": "Stable identifier for the membership. For the owner, who has no\nmembership row, this is the newsletter's `id` prefixed with\n`owner_`.\n"
          },
          "newsletter": {
            "description": "The newsletter. A `Ref` unless `newsletter` is named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "user": {
            "description": "The person. A `Ref` unless `user` is named in `?expand=`.\n",
            "anyOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/User"
              }
            ]
          },
          "role": {
            "$ref": "#/components/schemas/MemberRole"
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the person joined the team. Null for the owner, whose place\npredates the team.\n"
          }
        }
      },
      "SubscriberStatus": {
        "type": "string",
        "title": "SubscriberStatus",
        "description": "Where a subscription stands. Source of truth for a `commune`\nnewsletter. For a newsletter connected to an outside provider it\nreflects what Commune last saw of the provider's state.\n",
        "enum": [
          "subscribed",
          "unsubscribed",
          "bounced",
          "complained",
          "pending"
        ]
      },
      "Subscriber": {
        "type": "object",
        "title": "Subscriber",
        "description": "One person's membership of one newsletter. The same person subscribing\nto two newsletters is two subscribers, and one creator never sees the\nother's row.\n\nA subscriber may or may not have a Commune account. Someone who joined\nby email, or who arrived in an import from the newsletter's provider,\nhas an `email` and no `user`. Someone who joined through Commune has\nboth.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "email",
          "status",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "subscriber",
            "description": "Always `subscriber`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier for this membership."
          },
          "newsletter": {
            "description": "The newsletter subscribed to. A `Ref` unless `newsletter` is named\nin `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "user": {
            "description": "The Commune account behind the subscription, or `null` for someone\nwho joined by email without one. A `Ref` unless `user` is named in\n`?expand=`.\n",
            "anyOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/User"
              }
            ]
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "Where the newsletter reaches this person. Returned on every\nsubscriber, including one with a Commune account behind them.\n\nFor a subscriber with no account it is the address they subscribed\nwith. For one with an account it is the address on that account,\nwhich is where the article is actually delivered. Never null.\n\n**This is the one place in the API an address appears.** It is a\nproperty of the subscription rather than of the person, so it is\nnever on a public profile, never on an expanded author, and never\nreachable from a credential granted another newsletter. Reading it\nneeds `audience: read`, counts against the tighter `audience` rate\nlimit budget, and is recorded in Commune's audit log with the\ncredential and how many subscribers it read.\n"
          },
          "status": {
            "$ref": "#/components/schemas/SubscriberStatus"
          },
          "source": {
            "type": "string",
            "enum": [
              "commune",
              "imported",
              "unknown"
            ],
            "description": "How the subscription was made. `commune` when the person subscribed\nthrough Commune itself, by pressing subscribe, finishing signup or\naccepting an invitation. `imported` when they arrived in an import\nof the newsletter's provider list or a file.\n\n**Not the same question as whether Commune grew the list.** Somebody\nwho subscribes through Commune to a newsletter whose provider\nalready held their address is `commune` here, even though the\ncreator's list did not get longer.\n\n`unknown` for a subscription recorded before Commune kept this. It\nis stated rather than left out, so \"Commune does not know\" cannot be\nmistaken for `imported`.\n"
          },
          "tags": {
            "type": "array",
            "description": "The audience tags this subscriber holds, which is what decides which\ntag scoped articles reach them. Each entry is a `Ref` unless `tags` is\nnamed in `?expand=`.\n",
            "items": {
              "oneOf": [
                {
                  "$ref": "#/components/schemas/Ref"
                },
                {
                  "$ref": "#/components/schemas/Tag"
                }
              ]
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When Commune first recorded the subscription."
          },
          "synced_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When this row was last reconciled with the newsletter's provider.\nNull for a `commune` newsletter, which has no provider to reconcile\nwith.\n"
          }
        }
      },
      "ThreadVisibility": {
        "type": "string",
        "title": "ThreadVisibility",
        "description": "Where a thread is placed. `public` puts it on the global Commune feed\nand makes it readable by anyone. `subscribers` keeps it inside the\nnewsletter. `paid` narrows it further to the paying part of the\naudience. Set and changed by the newsletter's team.\n",
        "enum": [
          "public",
          "subscribers",
          "paid"
        ]
      },
      "Media": {
        "type": "object",
        "title": "Media",
        "description": "An image or file attached to a thread or a message.",
        "additionalProperties": false,
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Where the attachment is served from."
          },
          "type": {
            "type": [
              "string",
              "null"
            ],
            "description": "The attachment's media type when Commune recorded one, for example\n`image/png`. Null for an attachment old enough that none was\nrecorded.\n"
          },
          "thumbnail": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "A smaller rendition, when one was generated."
          }
        }
      },
      "Thread": {
        "type": "object",
        "title": "Thread",
        "description": "A conversation in a newsletter's community, together with the message\nthat opened it. Its replies are a separate collection.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "content",
          "visibility",
          "is_article_thread",
          "created_at",
          "last_activity_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "thread",
            "description": "Always `thread`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "short_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Eight character base62 identifier used by the thread's own URL at\n`/n/{handle}/chat/{short_id}`. Null for a thread Commune opened\nunder an article, which is reached through the article instead.\n"
          },
          "newsletter": {
            "description": "The community this thread lives in. A `Ref` unless `newsletter` is\nnamed in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "author": {
            "description": "Who opened the thread. A `Ref` unless `author` is named in\n`?expand=`.\n",
            "anyOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/User"
              }
            ]
          },
          "content": {
            "type": "string",
            "description": "The opening message. HTML, since people format what they write.\nTreat it as untrusted markup and render it in a sandboxed context.\n"
          },
          "media": {
            "type": "array",
            "description": "Attachments on the opening message.",
            "items": {
              "$ref": "#/components/schemas/Media"
            }
          },
          "visibility": {
            "$ref": "#/components/schemas/ThreadVisibility"
          },
          "is_article_thread": {
            "type": "boolean",
            "description": "`true` when Commune opened this thread under an article rather than\na person starting it. These are kept off the global feed, because\nthe article card already represents the conversation there.\n"
          },
          "article": {
            "description": "The article that opened this thread, when `is_article_thread` is\n`true`. `null` otherwise. A `Ref` unless `article` is named in\n`?expand=`.\n",
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Article"
              }
            ]
          },
          "reply_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Undeleted replies in the thread, at any depth."
          },
          "view_count": {
            "type": "integer",
            "minimum": 0,
            "description": "How many times the thread was opened."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the thread was opened."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the thread row last changed for any reason."
          },
          "edited_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the author last edited the opening message. Null when it was\nnever edited, which is what drives the edited marker in the product.\n"
          },
          "last_activity_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the thread last received a reply, or when it was opened if it\nnever did. This is the sort key for the thread list.\n"
          }
        }
      },
      "Message": {
        "type": "object",
        "title": "Message",
        "description": "A reply inside a thread. Commune allows two levels: a reply to the\nthread, and a reply to that reply. A deleted message is omitted from\nevery read rather than returned as a tombstone.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "thread",
          "newsletter",
          "content",
          "depth",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "message",
            "description": "Always `message`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "short_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Eight character base62 identifier used by the message's permalink.\nNull for a message old enough that none was assigned.\n"
          },
          "thread": {
            "description": "The thread this reply belongs to. A `Ref` unless `thread` is named\nin `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Thread"
              }
            ]
          },
          "newsletter": {
            "description": "The community the thread lives in, denormalised so a client does not\nhave to walk up to it. A `Ref` unless `newsletter` is named in\n`?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "author": {
            "description": "Who wrote the reply. A `Ref` unless `author` is named in `?expand=`.\n",
            "anyOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/User"
              }
            ]
          },
          "parent": {
            "description": "The message this one replies to, or `null` when it replies to the\nthread itself. A `Ref` unless `parent` is named in `?expand=`.\n",
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Message"
              }
            ]
          },
          "quoted": {
            "description": "The message this one quotes, when the author quoted rather than\nreplied. `null` otherwise. A `Ref` unless `quoted` is named in\n`?expand=`.\n",
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Message"
              }
            ]
          },
          "depth": {
            "type": "integer",
            "minimum": 1,
            "maximum": 2,
            "description": "`1` for a reply to the thread, `2` for a reply to a reply. Commune\ndoes not nest deeper, so a client can render the tree with a fixed\ntwo level layout.\n"
          },
          "content": {
            "type": "string",
            "description": "The message body as HTML. Treat it as untrusted markup and render it\nin a sandboxed context.\n"
          },
          "media": {
            "type": "array",
            "description": "Attachments on the message.",
            "items": {
              "$ref": "#/components/schemas/Media"
            }
          },
          "highlight": {
            "description": "The passage of an article this reply is anchored to, when the reader\nwrote it from a highlight. `null` otherwise. A `Ref` unless\n`highlight` is named in `?expand=`.\n",
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Highlight"
              }
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the reply was written."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the row last changed for any reason."
          },
          "edited_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the author last edited the text or attachments. Not touched by\nreactions or other side effects, so it is a faithful edited marker.\n"
          },
          "reactions": {
            "type": "array",
            "description": "The emoji reactions on this message, one entry per distinct emoji,\nmost used first. Empty when there are none. Each entry carries\n`users`, who left it, only when `reactions` is named in `?expand=`.\n",
            "items": {
              "$ref": "#/components/schemas/Reaction"
            }
          }
        }
      },
      "Reaction": {
        "type": "object",
        "title": "Reaction",
        "description": "One emoji on a message, and how many people left it.\n",
        "additionalProperties": false,
        "required": [
          "emoji",
          "count"
        ],
        "properties": {
          "emoji": {
            "type": "string",
            "description": "The emoji itself, as the character rather than a shortcode.",
            "examples": [
              "🎉"
            ]
          },
          "count": {
            "type": "integer",
            "minimum": 1,
            "description": "How many people left this emoji on the message."
          },
          "users": {
            "type": "array",
            "description": "Who left it, in the order they did. Present only when `reactions`\nis named in `?expand=`.\n",
            "items": {
              "$ref": "#/components/schemas/Ref"
            }
          }
        }
      },
      "User": {
        "type": "object",
        "title": "User",
        "description": "A person's public profile, and the whole of what this API returns about\nanybody other than the credential's own owner. Email address, theme,\nnotification preferences, push subscriptions, read state and saved\narticles are never carried.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "user",
            "description": "Always `user`."
          },
          "id": {
            "type": "string",
            "description": "Stable identifier."
          },
          "username": {
            "type": [
              "string",
              "null"
            ],
            "description": "The unique handle the profile resolves on at `/@{username}`. Null\nfor an account that has not finished signing up.\n"
          },
          "display_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "The name shown next to their messages and bylines."
          },
          "avatar": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Profile picture. Commune falls back to a generated avatar when the\nperson never set one, so this is rarely null in practice.\n"
          }
        }
      },
      "Me": {
        "type": "object",
        "title": "Me",
        "description": "The account behind the credential that asked.\n\nEverything `User` carries, plus the two properties a public profile\nwithholds. A separate schema rather than `User` with optional fields, so\na public profile cannot carry an email address at all. A client routing\non `object` gets `me` here and `user` there, so an absent email is never\nambiguous between \"not served\" and \"not set\".\n\nThe account's own edges are not properties of it. Which teams it is on,\nwhat it subscribes to, what it saved and what it liked are four\ncollections of their own: `GET /memberships`, `GET /subscriptions`,\n`GET /saved-articles` and `GET /liked-articles`.\n\nNot carried: theme and contrast, notification preferences, push\nsubscriptions and read state.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "me",
            "description": "Always `me`."
          },
          "id": {
            "type": "string",
            "description": "Stable identifier. The same value `User.id` carries, so a client\ncan match itself against an author or a message it has already\nread.\n"
          },
          "username": {
            "type": [
              "string",
              "null"
            ],
            "description": "The unique handle the public profile resolves on at\n`/@{username}`. Null for an account that has not finished signing\nup.\n"
          },
          "display_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "The name shown next to their messages and bylines. Null when it was\nnever set; a blank name is reported as null rather than as an empty\nstring.\n"
          },
          "avatar": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Profile picture. Commune falls back to a generated avatar when the\nperson never set one, so this is rarely null in practice.\n"
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "The address Commune sends this account's own mail to. Only ever\nthis account's own, and never returned for anybody else.\n"
          },
          "email_verified": {
            "type": "boolean",
            "description": "Whether the address above has been confirmed.\n"
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the account was created."
          }
        }
      },
      "Membership": {
        "type": "object",
        "title": "Membership",
        "description": "One newsletter team this account belongs to, seen from the account's\nside.\n\nNot `Member`, which is the same edge seen from the newsletter: that one\ncarries an expandable `user` and is read with a newsletter permission,\nthis one carries a bare `newsletter` reference and is read on the\naccount axis. They are kept apart so neither surface declares a property\nthe other must never serve.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "role"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "membership",
            "description": "Always `membership`."
          },
          "id": {
            "type": "string",
            "description": "Stable identifier for the membership. The same value `Member.id`\ncarries for the same edge, including the `owner_` prefixed form an\nowner gets.\n"
          },
          "newsletter": {
            "description": "The newsletter this place is on. A `Ref` unless `newsletter` is\nnamed in `?expand=`, and then a `NewsletterSummary`: the public half\nof that newsletter, which is all the account axis reads about a\npublication the credential holds no grant on.\n\nNever a `Newsletter`, which carries facts about the business behind\nthe publication. The two report different `object` values, so a\nclient can tell them apart without inspecting properties.\n",
            "anyOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/NewsletterSummary"
              }
            ]
          },
          "role": {
            "$ref": "#/components/schemas/MemberRole"
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the account joined that team. Null for an owner, whose place\npredates the team.\n"
          }
        }
      },
      "Subscription": {
        "type": "object",
        "title": "Subscription",
        "description": "One newsletter this account subscribes to, from the account's side.\n\nThe mirror of `Membership`. That one is a place on a newsletter's team,\nthis one is a place on its list; both are the same edge seen from the\nperson, both carry a bare `newsletter` reference, and neither says\nanything about the newsletter beyond an identifier.\n\nNever a `Subscriber`, which is the same edge seen from the newsletter\nand carries an email address, a lifecycle status, the tags the\nnewsletter has applied and where the subscriber was acquired. That is a\nnewsletter's record of a person; this is a person's record of a\nnewsletter.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "subscription",
            "description": "Always `subscription`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier for the subscription. The same value\n`Subscriber.id` carries for the same edge, so the newsletter's side\nand the reader's side name one subscription the same way.\n"
          },
          "newsletter": {
            "description": "The newsletter this subscription is to. A `Ref` unless `newsletter`\nis named in `?expand=`, and then a `NewsletterSummary`, exactly as\non `Membership`.\n",
            "anyOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/NewsletterSummary"
              }
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When Commune recorded the subscription, which is the same value\n`Subscriber.created_at` carries. For a subscription Commune learned\nfrom a newsletter's email provider this is when Commune first saw\nit, and that can be long after the person actually subscribed.\n"
          }
        }
      },
      "SavedArticle": {
        "type": "object",
        "title": "SavedArticle",
        "description": "One article this account put aside to read later.\n\nIt carries no identifier of its own, and that is the shape rather than\nan omission: a save has no identity apart from the pair of the person\nwho made it and the article it points at, and the person is the\ncredential that reads it. There is nothing to address it by, and no\noperation that would take one.\n\nAn entry is present only while the person may still read the article it\nnames, so a page of these is a live view rather than a log of what was\never saved.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "article"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "saved_article",
            "description": "Always `saved_article`."
          },
          "article": {
            "description": "The article that was saved. A `Ref` unless `article` is named in\n`?expand=`, and then an `ArticleSummary`: the title, the preview\nline, the cover and the publication date, without the body.\n\nExpanding it can never widen the page. The same two rules that\ndecide which saves appear at all are applied again when the article\nis resolved, so an entry whose article this person may no longer read\nstays a `Ref`. A `Ref` is also what a caller gets for an article that\nhas since been deleted, so treat the reference as \"no detail\navailable\" rather than as an error.\n",
            "anyOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/ArticleSummary"
              }
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the person saved it."
          }
        }
      },
      "LikedArticle": {
        "type": "object",
        "title": "LikedArticle",
        "description": "One article this account liked.\n\nProperty for property the same shape as `SavedArticle`, and a separate\nschema so a client routing on `object` can tell the two apart.\n\n**What they say differs.** A save is a list the person keeps for\nthemselves, and nothing else in this API reveals one. A like is a signal\nthe person gave a newsletter: its tally is public on `Article.stats`,\nand the newsletter it was aimed at reads who left it on its engagement\nstream and is pushed it as `article.liked`. Neither gesture is assembled\nacross the newsletters a person reads anywhere but here.\n\nCarries no identifier of its own. A like has no identity apart from the\nperson who left it and the article it points at.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "article"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "liked_article",
            "description": "Always `liked_article`."
          },
          "article": {
            "description": "The article that was liked. A `Ref` unless `article` is named in\n`?expand=`, and then an `ArticleSummary`, under exactly the rules\n`SavedArticle.article` sets out: the expansion is gated the same way\nthe page is, so it can never reveal an article the page withheld.\n",
            "anyOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/ArticleSummary"
              }
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the person liked it."
          }
        }
      },
      "NewsletterSummary": {
        "type": "object",
        "title": "NewsletterSummary",
        "description": "A newsletter as anybody sees it: what expanding the `newsletter` on a\nmembership or a subscription returns.\n\n**A different schema from `Newsletter`, not a trimmed one.**\n`Newsletter` carries things that belong to the business rather than to\nthe publication: which email provider the creator pays, who owns the\naccount, which article they have pinned. This schema holds the page a\nlogged out person can already open: the handle, the name, the\ndescription, the artwork, the accent colour, the links and the language.\nA property added to `Newsletter` later is not served here unless it is\nadded here too.\n\n`object` is `newsletter_summary` rather than `newsletter`, so a client\nrouting on `object` never has to guess whether a missing property was\nwithheld or unset.\n\nThe identifier is the newsletter's own, so it matches the `Ref` this\nreplaced and any other `Ref` to the same newsletter in a response.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "handle",
          "name"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "newsletter_summary",
            "description": "Always `newsletter_summary`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier, the same value `Newsletter.id` and a `Ref` to\nthis newsletter carry.\n"
          },
          "handle": {
            "type": "string",
            "description": "The unique handle the newsletter resolves on at `/n/{handle}`.\n"
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "The publication's name."
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "The one paragraph the newsletter introduces itself with."
          },
          "image_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Avatar, square."
          },
          "website_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "The newsletter's own site, when it has one apart from Commune."
          },
          "social_links": {
            "$ref": "#/components/schemas/SocialLinks"
          },
          "language": {
            "type": [
              "string",
              "null"
            ],
            "description": "The language the newsletter publishes in, as a BCP-47 primary\nsubtag.\n"
          }
        }
      },
      "ArticleSummary": {
        "type": "object",
        "title": "ArticleSummary",
        "description": "An article as anybody sees it, without its body: what expanding the\n`article` on a save or a like returns.\n\n**The expansion is gated exactly as the collection is.** The two rules\nthat decide whether an entry appears at all, that an article dated in\nthe future is not served and that an article stamped with an audience is\nserved only to the newsletter's team and to subscribers holding one of\nits tags, are applied again when the article is resolved. An entry the\nperson may no longer read keeps its `Ref`, so expanding a page can fill\nit in but can never lengthen it.\n\n**A different schema from `Article`, not a trimmed one.** `Article`\ncarries a lifecycle a reader has no part in: what state the dispatch is\nin, when a queued article may go out, whether it was imported. The body\nis absent too, as it would be on any list.\n\n`object` is `article_summary` rather than `article`, so a client routing\non `object` never has to guess whether a missing property was withheld\nor unset.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "short_id",
          "slug",
          "newsletter"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "article_summary",
            "description": "Always `article_summary`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier, the same value `Article.id` and a `Ref` to this\narticle carry.\n"
          },
          "short_id": {
            "type": "string",
            "description": "Eight character base62 identifier, unique across Commune and safe\nin a URL.\n",
            "examples": [
              "k7Rm2xQp"
            ]
          },
          "slug": {
            "type": "string",
            "description": "URL segment under the newsletter. With the newsletter's handle it\nmakes the permalink, `/n/{handle}/a/{slug}`.\n"
          },
          "newsletter": {
            "$ref": "#/components/schemas/Ref",
            "description": "The newsletter that published it, always as a reference. There is\nno nested `?expand=article.newsletter`. Expand `newsletter` on\n`GET /memberships` or `GET /subscriptions` to put names to these\nidentifiers.\n"
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Subject line of the article."
          },
          "preview_text": {
            "type": [
              "string",
              "null"
            ],
            "description": "The short line email clients show after the subject, and what\nCommune uses as the excerpt on a card.\n"
          },
          "image_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Cover image."
          },
          "external_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "The article's canonical URL on the newsletter's own provider, for an\nimported article. Null for one written in Commune.\n"
          },
          "posted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the article went out. Never ahead of now in a response: an article\ndated in the future is not resolved at all.\n"
          }
        }
      },
      "InsightVelocity": {
        "type": "string",
        "title": "InsightVelocity",
        "description": "Which way a reader's engagement is moving, from the last fourteen days\nagainst the fourteen before them. `steady` also covers a reader with no\nactivity in either window, so read it with `status`, where that reader\nis `dormant`.\n",
        "enum": [
          "rising",
          "cooling",
          "steady"
        ]
      },
      "InsightStatus": {
        "type": "string",
        "title": "InsightStatus",
        "description": "Where a reader sits in the newsletter's engagement ladder, from\n`dormant` at the bottom to `superfan` at the top.\n\nAssigned by rank inside the newsletter rather than against an absolute\nscore, so it is a statement about this audience and never comparable\nbetween two newsletters. It also means a reader can move without doing\nanything, because the people around them moved.\n",
        "enum": [
          "superfan",
          "engaged",
          "reader",
          "dormant"
        ]
      },
      "SubscriberInsight": {
        "type": "object",
        "title": "SubscriberInsight",
        "description": "One reader's engagement with one newsletter, scored across both the\nemail and the community. Recomputed on a schedule, not at read time.\n\nScores have no unit and no ceiling. They are sums of weighted actions,\nso they are meaningful ranked against each other inside one newsletter\nand meaningless compared between two.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "newsletter",
          "subscriber",
          "total_score",
          "community_score",
          "esp_score",
          "t1_score",
          "t2_score",
          "velocity",
          "status",
          "share_points"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "subscriber_insight",
            "description": "Always `subscriber_insight`."
          },
          "newsletter": {
            "description": "The newsletter the reader is scored against. A `Ref` unless\n`newsletter` is named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "subscriber": {
            "description": "The scored reader's membership of this newsletter. A `Ref` unless\n`subscriber` is named in `?expand=`, and then the full `Subscriber`,\n`email` and `status` included, exactly as\n`GET /subscribers/{subscriber}` returns it. Expanding it needs\n`audience: read` as well as `insights: read`, because it puts an\nemail address in the response. Scoring is attributed to a Commune\naccount, so this is always a subscriber who has one.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Subscriber"
              }
            ]
          },
          "total_score": {
            "type": "integer",
            "minimum": 0,
            "description": "The blended lifetime score, and the field this collection is ordered\nby. It is `community_score` plus `esp_score`.\n"
          },
          "community_score": {
            "type": "integer",
            "minimum": 0,
            "description": "The part of the score earned on Commune: reading, liking,\nhighlighting, replying and sharing.\n"
          },
          "esp_score": {
            "type": "integer",
            "minimum": 0,
            "description": "The part of the score earned in the inbox, from what the\nnewsletter's email provider reported. Always `0` for a `commune`\nnewsletter, whose email signal arrives through the send\npipeline instead.\n"
          },
          "t1_score": {
            "type": "integer",
            "minimum": 0,
            "description": "Points earned in the last fourteen days."
          },
          "t2_score": {
            "type": "integer",
            "minimum": 0,
            "description": "Points earned in the fourteen days before those. `velocity` is the\ncomparison of `t1_score` against this.\n"
          },
          "velocity": {
            "$ref": "#/components/schemas/InsightVelocity"
          },
          "status": {
            "$ref": "#/components/schemas/InsightStatus"
          },
          "share_points": {
            "type": "integer",
            "minimum": 0,
            "description": "The part of `total_score` earned by putting the newsletter in front\nof someone else rather than by consuming it. The signal a referral\nprogramme wants.\n"
          },
          "last_action_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When this reader last did anything that earned points. May be newer\nthan the scores beside it, because the scores are recomputed on a\nschedule and this is the raw event time. `null` for a reader who has\nnever acted.\n"
          },
          "synced_to_esp_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When Commune last wrote this reader's status back to the\nnewsletter's email provider, so the creator can segment on it there.\n`null` when it has never been synced, and always `null` for a\nnewsletter with no provider to sync to.\n"
          }
        }
      },
      "EngagementEventType": {
        "type": "string",
        "title": "EngagementEventType",
        "description": "What a reader did. The first five happen on Commune and the last two are\nreported by the newsletter's email provider, which is what `source`\nrecords.\n",
        "enum": [
          "view",
          "like",
          "link_click",
          "comment",
          "share",
          "email_open",
          "email_click"
        ]
      },
      "EngagementEventSource": {
        "type": "string",
        "title": "EngagementEventSource",
        "description": "Which side of the newsletter an event came from. `community` is\nbehaviour Commune observed itself. `esp` is behaviour the newsletter's\nemail provider reported, so it arrives on that provider's schedule and\nis only as complete as that provider's reporting.\n",
        "enum": [
          "community",
          "esp"
        ]
      },
      "EngagementEvent": {
        "type": "object",
        "title": "EngagementEvent",
        "description": "One scored action by one reader, unaggregated, so a consumer can build\nits own model rather than take Commune's scores. The subscriber insight\nscores are summed from exactly these.\n\nThey are engagement records, not the events Commune pushes to a\nconsumer. What Commune pushes is the `webhooks` block of this\ndocument.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "subscriber",
          "event_type",
          "source",
          "points",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "engagement_event",
            "description": "Always `engagement_event`."
          },
          "id": {
            "type": "string",
            "description": "Monotonically increasing identifier, returned as a string because it\noutgrows a double before a busy newsletter is done with it. This\ncollection is ordered by it and the cursor walks it, which is what\nmakes tailing safe.\n",
            "examples": [
              "4815162342"
            ]
          },
          "newsletter": {
            "description": "The newsletter the action was aimed at. A `Ref` unless `newsletter`\nis named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "subscriber": {
            "description": "Who acted. A `Ref` unless `subscriber` is named in `?expand=`.\nAttribution needs a Commune account, so an event is never recorded\nagainst an address the newsletter knows only from an import.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Subscriber"
              }
            ]
          },
          "event_type": {
            "$ref": "#/components/schemas/EngagementEventType"
          },
          "source": {
            "$ref": "#/components/schemas/EngagementEventSource"
          },
          "points": {
            "type": "integer",
            "description": "What this action contributed to the reader's score. The weights are\nCommune's own and may be retuned, so treat this as the value at the\ntime the event was recorded rather than a constant per `event_type`.\n"
          },
          "metadata": {
            "type": "object",
            "additionalProperties": true,
            "description": "What the action was aimed at, which varies by `event_type`: an\narticle id, a message id, a clicked URL. Loosely typed, and a key\npresent today may be absent tomorrow, so read it defensively.\n"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the reader acted."
          }
        }
      },
      "NewsletterStats": {
        "type": "object",
        "title": "NewsletterStats",
        "description": "A newsletter's headline numbers over one window: audience movement, what\nwas published, community activity and email performance.\n\nNothing here is a running total. Every number describes the resolved\nwindow, which the response echoes so a client can label a chart without\nrecomputing what it asked for.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "newsletter",
          "period_start",
          "period_end",
          "audience",
          "publishing",
          "community",
          "delivery"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "newsletter_stats",
            "description": "Always `newsletter_stats`."
          },
          "newsletter": {
            "description": "The newsletter these numbers describe. A `Ref` unless `newsletter`\nis named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "period_start": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the resolved window, inclusive. Echoed because `period`,\n`since` and `until` can each decide it.\n"
          },
          "period_end": {
            "type": "string",
            "format": "date-time",
            "description": "End of the resolved window, exclusive."
          },
          "audience": {
            "type": "object",
            "additionalProperties": false,
            "description": "How the list moved. Every count here comes from Commune's own\nsubscriber records, which are the source of truth only for a\n`commune` newsletter.\n",
            "required": [
              "known_subscribers",
              "net_change",
              "by_status"
            ],
            "properties": {
              "known_subscribers": {
                "type": "integer",
                "minimum": 0,
                "description": "How many subscribers Commune has a record of at `period_end`.\nNot the newsletter's subscriber count. For a newsletter\nconnected to an outside provider, Commune's records are a\npartial cache of that provider's list, filled in by imports and\nby people who joined through Commune, so this is a floor and not\na total. Ask the provider for the real number.\n"
              },
              "net_change": {
                "type": "integer",
                "description": "Subscribers gained minus subscribers lost inside the window,\nagainst the same partial record. Negative when the list shrank.\n"
              },
              "by_status": {
                "type": "object",
                "additionalProperties": false,
                "description": "How the known subscribers split across the subscription states\nat `period_end`. The keys are the `SubscriberStatus` values.\n",
                "required": [
                  "subscribed",
                  "unsubscribed",
                  "bounced",
                  "complained",
                  "pending"
                ],
                "properties": {
                  "subscribed": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Mailable."
                  },
                  "unsubscribed": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Opted out, and kept so the history survives."
                  },
                  "bounced": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Undeliverable, and suppressed by the send pipeline."
                  },
                  "complained": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Reported the email as spam, and suppressed."
                  },
                  "pending": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Recorded but not yet confirmed as mailable."
                  }
                }
              }
            }
          },
          "publishing": {
            "type": "object",
            "additionalProperties": false,
            "description": "Cadence. These are counts of articles, never counts of emails.\n",
            "required": [
              "sent",
              "scheduled"
            ],
            "properties": {
              "sent": {
                "type": "integer",
                "minimum": 0,
                "description": "Articles that went out inside the window and that Commune has a\nrecord of. Commune's record is not exhaustive, so never present\nthis as the number of emails the newsletter sent. That number\nlives on a send.\n\nFor a key that holds only read permissions, only the articles it\ncan read: published and not restricted to a tag.\n"
              },
              "scheduled": {
                "type": [
                  "integer",
                  "null"
                ],
                "minimum": 0,
                "description": "Articles queued to go out after `period_end`. A forward looking\ncount, so it does not move when the window does.\n\n`null` for a key that holds only read permissions. Queued\narticles are unpublished, which such a key may not see, so it is\nnot told how many there are. `null` is not a count of zero: the\nnewsletter may well have articles queued.\n"
              }
            }
          },
          "community": {
            "type": "object",
            "additionalProperties": false,
            "description": "What happened in the newsletter's community inside the window. All\nfour are Commune side counts with no email equivalent.\n\nFor a key that holds only read permissions, all four cover only\nwhat it can read: public conversations and the replies in them, and\nhighlights on articles it can read. An article's own discussion is\nvisible to subscribers only, so it is not counted for such a key.\n",
            "required": [
              "threads",
              "messages",
              "highlights",
              "reactions"
            ],
            "properties": {
              "threads": {
                "type": "integer",
                "minimum": 0,
                "description": "Conversations started, including the ones Commune opens under a\npublished article.\n"
              },
              "messages": {
                "type": "integer",
                "minimum": 0,
                "description": "Replies posted inside those conversations. An article's comments\nare replies in its thread, so they are counted here.\n"
              },
              "highlights": {
                "type": "integer",
                "minimum": 0,
                "description": "Passages readers marked inside an article."
              },
              "reactions": {
                "type": "integer",
                "minimum": 0,
                "description": "Emoji reactions added to a message."
              }
            }
          },
          "delivery": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": false,
            "description": "How the email performed across the articles sent inside the window.\n`null` for a newsletter Commune does not send, because the provider\nthat sent the mail holds those numbers and does not hand them over\nper article.\n",
            "required": [
              "open_rate",
              "click_rate"
            ],
            "properties": {
              "open_rate": {
                "type": [
                  "number",
                  "null"
                ],
                "minimum": 0,
                "maximum": 1,
                "description": "Opens over deliveries, as a fraction between `0` and `1`.\n`null` when nothing was delivered in the window, which is not\nthe same as a rate of zero. Open tracking is unreliable by\nnature: a mail client that prefetches images inflates it and one\nthat blocks them suppresses it.\n"
              },
              "click_rate": {
                "type": [
                  "number",
                  "null"
                ],
                "minimum": 0,
                "maximum": 1,
                "description": "Recipients who clicked at least one link, over deliveries, as a\nfraction between `0` and `1`. `null` when nothing was delivered\nin the window.\n"
              }
            }
          }
        }
      },
      "NewsletterGrowth": {
        "type": "object",
        "title": "NewsletterGrowth",
        "description": "Where a newsletter's new subscribers came from inside one window.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "newsletter",
          "period_start",
          "period_end",
          "by_source"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "newsletter_growth",
            "description": "Always `newsletter_growth`."
          },
          "newsletter": {
            "description": "The newsletter that grew. A `Ref` unless `newsletter` is named in\n`?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "period_start": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the resolved window, inclusive."
          },
          "period_end": {
            "type": "string",
            "format": "date-time",
            "description": "End of the resolved window, exclusive."
          },
          "by_source": {
            "type": "array",
            "description": "One entry per acquisition source that produced at least one\nsubscriber inside the window, largest first. A source that produced\nnone is omitted rather than returned as a zero.\n",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "source",
                "known_subscribers"
              ],
              "properties": {
                "source": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Which import brought these people in: the provider's slug, or\n`csv` for a file.\n\n`null` is the ordinary outcome for anyone who subscribed\nthrough Commune rather than arriving in an import, because\nonly an import stamps this. It also covers rows written\nbefore Commune recorded anything here, which cannot be\nattributed after the fact. The two are not told apart, so a\nnull bucket is not a count of Commune arrivals; the\nsubscriber's own `source` is.\n\nTwo further values, `commune` and `imported`, appear only on\nrows labelled once when the field was introduced and are\nwritten by nothing today.\n",
                  "examples": [
                    "kit"
                  ]
                },
                "known_subscribers": {
                  "type": "integer",
                  "minimum": 0,
                  "description": "How many subscriptions Commune recorded from this source\ninside the window. It counts arrivals Commune saw, so for a\nnewsletter connected to an outside provider it misses anyone\nwho joined at the provider between two imports.\n"
                }
              }
            }
          }
        }
      },
      "TimeseriesMetric": {
        "type": "string",
        "title": "TimeseriesMetric",
        "description": "Which quantity a series buckets. Each value is a count of things that\nhappened inside a bucket, never a running total, so a client that wants\na cumulative line adds them up itself.\n",
        "enum": [
          "subscribers",
          "sends",
          "opens",
          "clicks",
          "threads",
          "messages"
        ]
      },
      "TimeseriesInterval": {
        "type": "string",
        "title": "TimeseriesInterval",
        "description": "How wide each bucket is. A week starts on Monday and a month on the\nfirst, both in UTC, so buckets line up across two newsletters in\ndifferent time zones.\n",
        "enum": [
          "day",
          "week",
          "month"
        ]
      },
      "Timeseries": {
        "type": "object",
        "title": "Timeseries",
        "description": "One metric bucketed over a window. The shape a chart consumes, and the\nonly operation in this domain that is not a snapshot.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "newsletter",
          "metric",
          "interval",
          "period_start",
          "period_end",
          "buckets"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "timeseries",
            "description": "Always `timeseries`."
          },
          "newsletter": {
            "description": "The newsletter the series describes. A `Ref` unless `newsletter` is\nnamed in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "metric": {
            "$ref": "#/components/schemas/TimeseriesMetric"
          },
          "interval": {
            "$ref": "#/components/schemas/TimeseriesInterval"
          },
          "period_start": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the resolved window, inclusive. The first bucket starts\nhere.\n"
          },
          "period_end": {
            "type": "string",
            "format": "date-time",
            "description": "End of the resolved window, exclusive."
          },
          "buckets": {
            "type": "array",
            "description": "The series, oldest first. Buckets are contiguous and cover the whole\nwindow, so an empty bucket is returned with a `value` of `0` rather\nthan omitted and a client never has to fill gaps.\n",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "ts",
                "value"
              ],
              "properties": {
                "ts": {
                  "type": "string",
                  "format": "date-time",
                  "description": "The instant the bucket opens, in UTC. A bucket covers `ts` up\nto the next bucket's `ts`.\n"
                },
                "value": {
                  "type": "integer",
                  "minimum": 0,
                  "description": "How much of `metric` fell inside this bucket. A count for that\nbucket alone, not a running total.\n"
                }
              }
            }
          }
        }
      },
      "ArticlePerformance": {
        "type": "object",
        "title": "ArticlePerformance",
        "description": "One article measured on both sides at once: what the email did, and what\nthe community did with it afterwards.\n\nNot to be confused with `ArticleStats`, the small public tally that\nhangs off an article itself. This is the `insights` report.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "article",
          "email",
          "community"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "article_stats",
            "description": "Always `article_stats`."
          },
          "article": {
            "description": "The article measured. A `Ref` unless `article` is named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Article"
              }
            ]
          },
          "email": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": false,
            "description": "What happened in the inbox, counted per recipient from Commune's own\nsend records.\n\n`null` for an article Commune did not send: one imported from an\noutside provider, which mailed it without handing Commune the\noutcome, and one that has not been sent yet.\n\nThese are counts of recipients, not rates. Divide by `delivered`\nrather than by `recipients` to get the rates a provider quotes.\n",
            "required": [
              "recipients",
              "delivered",
              "opened",
              "clicked",
              "bounced",
              "unsubscribed"
            ],
            "properties": {
              "recipients": {
                "type": "integer",
                "minimum": 0,
                "description": "Addresses the dispatch was aimed at. For a tag scoped article this\nis the size of that segment, not of the whole list.\n"
              },
              "delivered": {
                "type": "integer",
                "minimum": 0,
                "description": "Recipients the provider accepted and delivered to."
              },
              "opened": {
                "type": "integer",
                "minimum": 0,
                "description": "Recipients who opened at least once, not the number of opens.\nUndercounts readers whose mail client blocks the tracking pixel\nand overcounts the ones whose client prefetches it.\n"
              },
              "clicked": {
                "type": "integer",
                "minimum": 0,
                "description": "Recipients who clicked at least one link, not the number of\nclicks. Which links they clicked is not on this report.\n"
              },
              "bounced": {
                "type": "integer",
                "minimum": 0,
                "description": "Recipients the provider could not deliver to. A hard bounce also\nsuppresses that subscriber for later sends.\n"
              },
              "unsubscribed": {
                "type": "integer",
                "minimum": 0,
                "description": "Recipients who opted out from this article, where the opt out\ncarried enough to attribute it. Best effort: someone who\nunsubscribed inside the app instead is not counted here.\n"
              }
            }
          },
          "community": {
            "type": "object",
            "additionalProperties": false,
            "description": "What happened on Commune. Computed at read time and still moving, so\ntwo reads a week apart legitimately disagree.\n",
            "required": [
              "views",
              "likes",
              "saves",
              "highlights",
              "thread_messages",
              "participants"
            ],
            "properties": {
              "views": {
                "type": "integer",
                "minimum": 0,
                "description": "People who opened the article on Commune, counted once each rather\nthan once per visit.\n"
              },
              "likes": {
                "type": "integer",
                "minimum": 0,
                "description": "People who liked the article."
              },
              "saves": {
                "type": "integer",
                "minimum": 0,
                "description": "People who put the article in their own reading list. Who they are\nstays private.\n"
              },
              "highlights": {
                "type": "integer",
                "minimum": 0,
                "description": "Passages readers marked inside the body. The sentences worth\nreading before writing the next article.\n"
              },
              "thread_messages": {
                "type": "integer",
                "minimum": 0,
                "description": "Replies in the article's discussion. Commune has no separate\ncomments store: an article's discussion is a chat thread like any\nother, so this counts the undeleted replies hanging off it, and\nit is `0` for an article nobody has discussed.\n"
              },
              "participants": {
                "type": "integer",
                "minimum": 0,
                "description": "Distinct people who replied, so a reader who posted six times\ncounts once. The number that says whether an article started a\nconversation or an argument between two people.\n"
              }
            }
          }
        }
      },
      "Actor": {
        "type": [
          "object",
          "null"
        ],
        "description": "Who caused the change, and `null` when nobody outside Commune did.\n\nPopulated on a change made through this API's write operations, and null on every other change: an edit a creator made in the product, an import arriving from a provider, a scheduled job, a delivery result reported by the sending provider. Null is therefore the common case and stays a legitimate value. Read it as \"this change came in through the API under this credential\", never as \"nothing caused this\".\n\nIt never names a person, only the credential.\n\nIt answers \"did this change come in through the API, and under which credential\". It is **not** the field to drop the echo of your own write with: use `idempotency_key`, which you chose and therefore already know, whereas `id` here is Commune's own identifier for your credential and no operation reports it back to you. A creator can read that identifier in Commune, but it changes when the credential is replaced, so matching on it puts the loop back silently after a rotation.",
        "additionalProperties": false,
        "required": [
          "type",
          "id"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "user",
              "api_key",
              "system"
            ],
            "description": "`api_key` for a change made through this API, which is the only value emitted today. `user` is reserved for a change a named person made through an authenticated session and `system` for an unattended job; neither is emitted.\n\nAn OAuth access token reports as `api_key` as well. The two credentials are interchangeable everywhere else in this API, and a separate value here would be a distinction a consumer cannot act on."
          },
          "id": {
            "type": "string",
            "description": "Identifier of the actor within its `type`. For `api_key` it is the credential's own id and never its secret, and it is the same value that appears in a creator's list of credentials, so an event can be traced back to the integration that caused it."
          },
          "label": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human-readable name for display. Best effort, may be null."
          }
        }
      },
      "ArticleLikedData": {
        "type": "object",
        "title": "Article like changed body",
        "description": "Body of `article.liked`. Which article, who, which way the like moved, and the tally afterwards.",
        "required": [
          "article_id",
          "title",
          "reader",
          "direction",
          "like_count",
          "changed_at"
        ],
        "properties": {
          "article_id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Canonical public URL. Follows the newsletter's custom website domain when it has an active one, otherwise the Commune-hosted path."
          },
          "reader": {
            "$ref": "#/components/schemas/UserRef",
            "description": "The person who gave or withdrew the like. A like requires a Commune account, so this is null only in the case `UserRef` describes, an account that has since been deleted."
          },
          "direction": {
            "type": "string",
            "enum": [
              "added",
              "removed"
            ],
            "description": "`added` when the like was given, `removed` when it was taken back."
          },
          "like_count": {
            "type": "integer",
            "minimum": 0,
            "description": "The article's whole like tally after this change, counted in the same transaction that made it. The same number `Article.stats.likes` reports. Carried on both directions, so a consumer never has to add or subtract to stay correct."
          },
          "changed_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the like was given or withdrawn. Equal to `occurred_at`."
          }
        }
      },
      "ArticleLikedEvent": {
        "title": "Article like changed",
        "description": "A reader liked an article, or took the like back.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "article.liked"
              },
              "data": {
                "$ref": "#/components/schemas/ArticleLikedData"
              }
            }
          }
        ]
      },
      "ArticlePublishedData": {
        "type": "object",
        "required": [
          "article_id",
          "title",
          "published_at",
          "source"
        ],
        "properties": {
          "article_id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "slug": {
            "type": [
              "string",
              "null"
            ]
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Canonical public URL. Follows the newsletter's custom website domain when it has an active one, otherwise the Commune-hosted path."
          },
          "published_at": {
            "type": "string",
            "format": "date-time",
            "description": "The article's own `posted_at`. Normally equal to `occurred_at`, but an import can backdate it to the original publication time."
          },
          "source": {
            "type": "string",
            "enum": [
              "commune_send",
              "import"
            ],
            "description": "`commune_send` for an article Commune emailed itself, `import` for a post pulled in from a connected ESP or RSS feed."
          },
          "audience_scoped": {
            "type": "boolean",
            "description": "True when the article is restricted to specific subscriber tags rather than the whole list. Which tags is not carried here; read `GET /articles/{article}` for that."
          }
        }
      },
      "ArticlePublishedEvent": {
        "title": "Article published",
        "description": "An article became publicly readable.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "article.published"
              },
              "data": {
                "$ref": "#/components/schemas/ArticlePublishedData"
              }
            }
          }
        ]
      },
      "ArticleReadData": {
        "type": "object",
        "title": "Article read body",
        "description": "Body of `article.read`. Which article, who read it, and when they crossed from unread to read. There is no count here and no way to build one: the topic reports first reads by signed-in people only.",
        "required": [
          "article_id",
          "title",
          "reader",
          "read_at"
        ],
        "properties": {
          "article_id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Canonical public URL. Follows the newsletter's custom website domain when it has an active one, otherwise the Commune-hosted path."
          },
          "reader": {
            "$ref": "#/components/schemas/UserRef",
            "description": "The person who read it. Never a stand-in for an anonymous reader: an anonymous read produces no message at all rather than one with this field empty, because Commune records nothing durable for it and a message built from nothing would be a message the reader could fabricate. Null only in the case `UserRef` describes, an account that has since been deleted."
          },
          "read_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the article became read for this person. Equal to `occurred_at`. It does not move afterwards, because this crossing happens once."
          }
        }
      },
      "ArticleReadEvent": {
        "title": "Article read",
        "description": "A reader stayed with an article long enough to have read it.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "article.read"
              },
              "data": {
                "$ref": "#/components/schemas/ArticleReadData"
              }
            }
          }
        ]
      },
      "ArticleScheduledData": {
        "type": "object",
        "required": [
          "article_id",
          "title",
          "scheduled_for"
        ],
        "properties": {
          "article_id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "scheduled_for": {
            "type": "string",
            "format": "date-time",
            "description": "When the send is due. Always in the future at the time of this event, and always on a five-minute boundary."
          }
        }
      },
      "ArticleScheduledEvent": {
        "title": "Article scheduled",
        "description": "An article written in Commune was queued for a future send.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "article.scheduled"
              },
              "data": {
                "$ref": "#/components/schemas/ArticleScheduledData"
              }
            }
          }
        ]
      },
      "BillingSubscriptionUpdatedData": {
        "type": "object",
        "required": [
          "subscription_id",
          "plan",
          "status"
        ],
        "properties": {
          "subscription_id": {
            "type": "string",
            "format": "uuid",
            "description": "Commune's own identifier for the newsletter's subscription, one per `commune` newsletter. Not a Stripe id; Stripe identifiers are never exposed."
          },
          "plan": {
            "type": "string",
            "enum": [
              "creator",
              "enterprise"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "trialing",
              "active",
              "past_due",
              "canceled",
              "trial_expired"
            ]
          },
          "previous_status": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "trialing",
              "active",
              "past_due",
              "canceled",
              "trial_expired",
              null
            ],
            "description": "The state being left. Null when it could not be established, so treat this as a hint and the new `status` as the truth."
          },
          "trial_ends_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "current_period_start": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "current_period_end": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "cancel_at_period_end": {
            "type": "boolean",
            "description": "The subscription is still active but will not renew. Distinct from `status: canceled`, which means it already stopped."
          }
        }
      },
      "BillingSubscriptionUpdatedEvent": {
        "title": "Billing subscription state changed",
        "description": "The newsletter's own Commune subscription changed state.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "billing.subscription.updated"
              },
              "data": {
                "$ref": "#/components/schemas/BillingSubscriptionUpdatedData"
              }
            }
          }
        ]
      },
      "CommunityMember": {
        "type": "object",
        "title": "CommunityMember",
        "description": "One person's public place in a newsletter's community.\n\nThe same membership a `Subscriber` describes, seen from outside, and the\ntwo share an `id`. A `Subscriber` carries an address; this carries a\nprofile the person already publishes. Both need `audience: read`.\n\n**Only somebody with a Commune account is ever one of these.** A\nsubscription that is an address and no account has no person to name, so\nit is left out of the collection rather than returned with a null\n`user`.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "user",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "community_member",
            "description": "Always `community_member`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier for the membership. The same value `Subscriber.id`\ncarries for the same person on the same newsletter, so a creator\nholding both views can join them without a second lookup.\n"
          },
          "newsletter": {
            "description": "The newsletter whose community this is. A `Ref` unless `newsletter`\nis named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "user": {
            "description": "The person. Never null: a membership with no account behind it is\nnot returned at all. A `Ref` unless `user` is named in `?expand=`.\n",
            "anyOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/User"
              }
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When they joined this newsletter's community. Their account may be\nmuch older: this is the day they joined here, not the day they\njoined Commune.\n"
          }
        }
      },
      "DeliveryBouncedEvent": {
        "title": "Delivery bounced",
        "description": "The message could not be delivered.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "delivery.bounced"
              },
              "data": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/DeliveryEventData"
                  },
                  {
                    "type": "object",
                    "required": [
                      "reason"
                    ],
                    "properties": {
                      "reason": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "maxLength": 1000,
                        "description": "The provider's bounce message or type, whichever it supplied. Free text for display. Hard and soft bounces are not distinguished today because Commune stores only this string."
                      }
                    }
                  }
                ]
              }
            }
          }
        ]
      },
      "DeliveryClickedEvent": {
        "title": "Delivery link clicked",
        "description": "The recipient clicked a tracked link.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "delivery.clicked"
              },
              "data": {
                "$ref": "#/components/schemas/DeliveryEventData"
              }
            }
          }
        ]
      },
      "DeliveryComplainedEvent": {
        "title": "Delivery marked as spam",
        "description": "The recipient reported the message as spam.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "delivery.complained"
              },
              "data": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/DeliveryEventData"
                  },
                  {
                    "type": "object",
                    "required": [
                      "reason"
                    ],
                    "properties": {
                      "reason": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "maxLength": 1000,
                        "description": "Why the provider recorded a complaint. Providers rarely elaborate, so expect a constant string."
                      }
                    }
                  }
                ]
              }
            }
          }
        ]
      },
      "DeliveryDeliveredEvent": {
        "title": "Delivery accepted",
        "description": "The provider confirmed the message reached the recipient server.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "delivery.delivered"
              },
              "data": {
                "$ref": "#/components/schemas/DeliveryEventData"
              }
            }
          }
        ]
      },
      "DeliveryEventData": {
        "type": "object",
        "title": "Delivery event body",
        "description": "Shared body of the five `delivery.*` topics. One delivery to one recipient, identified by both Commune's ids and the provider's message id, so a consumer can reconcile with its own provider logs.",
        "required": [
          "delivery_id",
          "article_id",
          "send_id",
          "subscriber_id",
          "email",
          "status"
        ],
        "properties": {
          "delivery_id": {
            "type": "string",
            "format": "uuid",
            "description": "Commune's identifier for this delivery."
          },
          "article_id": {
            "type": "string",
            "format": "uuid"
          },
          "send_id": {
            "type": "string",
            "format": "uuid",
            "description": "The send run this delivery belonged to."
          },
          "subscriber_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "The address as it was at send time. Snapshotted onto the delivery row, so it does not follow a later change to the subscriber."
          },
          "provider_message_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The email provider's id for this message, the correlator the inbound webhook matched on. Null only if the row was written without one."
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "sent",
              "delivered",
              "opened",
              "clicked",
              "bounced",
              "complained",
              "failed"
            ],
            "description": "The delivery row's status after this event was applied. Because the state machine is forward-only, it is not always the event's own name: an open recorded after a click leaves the row on `clicked`."
          }
        }
      },
      "DeliveryOpenedEvent": {
        "title": "Delivery opened",
        "description": "The recipient opened the message.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "delivery.opened"
              },
              "data": {
                "$ref": "#/components/schemas/DeliveryEventData"
              }
            }
          }
        ]
      },
      "DomainVerifiedData": {
        "type": "object",
        "required": [
          "domain_id",
          "domain",
          "verified_at"
        ],
        "properties": {
          "domain_id": {
            "type": "string",
            "format": "uuid"
          },
          "domain": {
            "type": "string",
            "description": "The hostname now serving the newsletter's site. Always a subdomain: apex domains cannot CNAME and are rejected before they get this far."
          },
          "ssl_status": {
            "type": [
              "string",
              "null"
            ],
            "description": "The certificate authority's sub-status, kept for display. The aggregate `active` state is what Commune actually branches on."
          },
          "verified_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "DomainVerifiedEvent": {
        "title": "Custom website domain verified",
        "description": "A creator's custom website domain went live.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "domain.verified"
              },
              "data": {
                "$ref": "#/components/schemas/DomainVerifiedData"
              }
            }
          }
        ]
      },
      "EventEnvelope": {
        "type": "object",
        "title": "Event envelope",
        "description": "The shape every message on every topic shares. The concrete event schemas below narrow `type` and fill in `data`; nothing else varies.\n\nTwo fields describe where a change came from rather than what changed: `actor` and `idempotency_key`. Both are filled in on a change made through this API's write operations and are `null` on every other change, which is most of them. Null is the common case and is not a signal that something went missing.\n\nDeduping: `id` is unique per event and stable across redeliveries of that event, which makes it the correct dedupe key. `idempotency_key` is a second, different tool, and it answers two questions `id` cannot. It collapses the events caused by one retried write, because a retry that really did the work twice produces two ids and one key. And it tells a consumer which events its own writes caused, which is what stops a consumer that reacts by writing from reacting to itself for ever. See `idempotency_key` below.",
        "required": [
          "id",
          "type",
          "api_version",
          "occurred_at",
          "newsletter_id",
          "actor",
          "idempotency_key",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique id for this event. Stable across redeliveries, so it is the dedupe key. Assigned when the state change is recorded rather than when the message is dispatched.",
            "examples": [
              "018f2a8b-6c4b-7d2e-9f11-6a1c3d5e7b90"
            ]
          },
          "type": {
            "type": "string",
            "description": "The topic name, identical to the channel address. Route on this.",
            "examples": [
              "article.published"
            ]
          },
          "api_version": {
            "type": "string",
            "description": "The `Commune-Version` value this payload conforms to. Present on the message itself, not only on the request header, so an event persisted to a consumer's own store stays self-describing.",
            "examples": [
              "2026-08-26"
            ]
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time",
            "description": "RFC 3339 timestamp of the state change, not of the delivery attempt. Use it to order events, since delivery order is not guaranteed."
          },
          "newsletter_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The newsletter the change belongs to. This is the tenant boundary: a consumer only ever receives events for newsletters its credential can read. Null only for events that are genuinely not newsletter-scoped, which none of the topics Commune publishes currently are."
          },
          "actor": {
            "$ref": "#/components/schemas/Actor"
          },
          "idempotency_key": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "The `Idempotency-Key` of the API write that caused this change, so a consumer can tie an event back to its own request and collapse the duplicates a retried write would otherwise produce.\n\n**This is how a consumer that writes recognises its own work.** Keep the keys you send, and skip any event carrying one of them. You chose the value, so you know it before the event arrives. `actor` cannot do this job, because Commune's id for your credential is not something this API reports to you.\n\nNull for every change that did not come in through this API, which is most of them: an edit made in Commune, an import, a scheduled job and a delivery result all have no originating request to key on.\n\n**It reaches every endpoint registered for the newsletter**, not only the one that made the write, so choose opaque keys such as UUIDs rather than keys carrying your own business identifiers."
          },
          "data": {
            "type": "object",
            "description": "Event-specific body. Narrowed by each concrete event schema below."
          }
        }
      },
      "HighlightCreatedData": {
        "type": "object",
        "title": "Highlight created body",
        "description": "Body of `highlight.created`. The passage, where it sits in the article, and an opaque owner token. No `user_id`: Commune does not attribute a highlight to a named reader.",
        "required": [
          "highlight_id",
          "article_id",
          "quote",
          "start_offset",
          "end_offset",
          "owner_key",
          "message_id",
          "created_at"
        ],
        "properties": {
          "highlight_id": {
            "type": "string",
            "format": "uuid",
            "description": "Commune's identifier for the highlight."
          },
          "article_id": {
            "type": "string",
            "format": "uuid"
          },
          "quote": {
            "type": "string",
            "maxLength": 5000,
            "description": "The marked text itself, as plain text, capped at 5000 characters. The surrounding prefix and suffix that let a client re-anchor the range are not here; read `GET /highlights/{highlight}` for those."
          },
          "start_offset": {
            "type": "integer",
            "minimum": 0,
            "description": "Where the passage starts, as a character offset into the article's normalised plain text. Always less than `end_offset`."
          },
          "end_offset": {
            "type": "integer",
            "minimum": 1,
            "description": "Where the passage ends, in the same offsets."
          },
          "owner_key": {
            "type": "string",
            "description": "An opaque, stable per highlighter value, scoped to this one article. The same value `GET /highlights/{highlight}` returns. Group by it to tell one reader's marks apart from another's. It cannot be resolved to a person and does not correlate across articles.",
            "examples": [
              "4f2a9c1e7b3d6a05"
            ]
          },
          "message_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The chat message the reader wrote from this passage, when the highlight was created as a discussion. Null for a silent highlight, and it stays null on this topic: attaching a message later updates the row rather than creating one."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "HighlightCreatedEvent": {
        "title": "Highlight created",
        "description": "A reader marked a passage of an article.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "highlight.created"
              },
              "data": {
                "$ref": "#/components/schemas/HighlightCreatedData"
              }
            }
          }
        ]
      },
      "ImportCompletedData": {
        "type": "object",
        "title": "Import completed body",
        "description": "Body of `import.completed`. Which kind of run finished, where its rows came from, and how much it moved.",
        "required": [
          "kind",
          "source",
          "imported_count",
          "completed_at"
        ],
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "articles",
              "subscribers",
              "migration"
            ],
            "description": "`articles` and `subscribers` ingest rows. `migration` is the finalize step that makes a newsletter `commune`, which ingests nothing."
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Where the rows came from: an ESP slug such as `kit` or `beehiiv`, `csv` for an uploaded list, or `rss` for a feed. Null when the run has no single source, which is the `migration` case.",
            "examples": [
              "kit"
            ]
          },
          "imported_count": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "description": "Subscribers that actually landed. Not the size of what was offered: both import paths skip anybody already on the list and leave their existing status alone. Null for `migration`, which ingests nothing.\n\nSubscribers the run could not take are not reported. One bad entry never ends a run, and nothing counts the ones that failed, so a number here would be a guess."
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the run began. Null when it was not recorded, so treat the duration as best effort."
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the run ended. Equal to `occurred_at`."
          }
        }
      },
      "ImportCompletedEvent": {
        "title": "Import finished",
        "description": "An article, subscriber or migration run finished.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "import.completed"
              },
              "data": {
                "$ref": "#/components/schemas/ImportCompletedData"
              }
            }
          }
        ]
      },
      "MessageCreatedData": {
        "type": "object",
        "required": [
          "message_id",
          "thread_id",
          "thread_level",
          "content",
          "created_at"
        ],
        "properties": {
          "message_id": {
            "type": "string",
            "format": "uuid"
          },
          "short_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri"
          },
          "thread_id": {
            "type": "string",
            "format": "uuid",
            "description": "The top-level thread this reply belongs to."
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The message being replied to. Equal to `thread_id` for a direct reply, a sibling message for a nested one."
          },
          "thread_level": {
            "type": "integer",
            "enum": [
              1,
              2
            ],
            "description": "Nesting depth. 1 is a reply to the thread, 2 is a reply to a reply, and nesting stops there."
          },
          "author": {
            "$ref": "#/components/schemas/UserRef"
          },
          "content": {
            "type": "string",
            "maxLength": 5000
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "MessageCreatedEvent": {
        "title": "Message created",
        "description": "A reply was posted inside a thread.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "message.created"
              },
              "data": {
                "$ref": "#/components/schemas/MessageCreatedData"
              }
            }
          }
        ]
      },
      "Send": {
        "type": "object",
        "title": "Send",
        "description": "One dispatch of one article to a newsletter's list.\n\nThe three counts are a `send.completed` event's three counts under the\nsame names. `recipient_count` is the audience size fixed at the moment\nthe run started, which is the stable denominator for any rate.\n`sent_count` is what the sending provider accepted at dispatch, which is\n**not** a confirmed delivery: confirmations arrive afterwards, one per\nrecipient, as `delivery.delivered`, and are never counted back into this\nshape. `failed_count` is what the provider rejected outright.\n\n**Read `completed_at` before reading either count.** It is null while\nthe run is still going, and until it is stamped `sent_count` and\n`failed_count` are both `0`. They are not a progress report: the\ndispatch settles them once, at the end.\n\n**The counts are what the run finished on, and they stay there.** A\nlater re-attempt of the recipients this run could not reach does not\nmove them, so this resource never disagrees with the `send.completed`\nevent that announced it.\n\n**Opens and clicks are not here**, and neither is a `status`. Opens and\nclicks come from the sending provider on its own schedule. A run that\nbroke is reported by the article's own `status`.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "article",
          "newsletter",
          "recipient_count",
          "sent_count",
          "failed_count",
          "created_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "send",
            "description": "Always `send`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier for the run. The `send_id` on `send.completed`\nand on `send.failed` is this value.\n"
          },
          "article": {
            "description": "The article that was dispatched. A `Ref` unless `article` is named in\n`?expand=`, and `null` when the article is one this credential may not\nread. An article dated in the future is hidden from everybody,\nincluding the newsletter's own team, so its run reports `null` here\nrather than a reference whose own operation would answer `404`.\n",
            "anyOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Article"
              }
            ]
          },
          "newsletter": {
            "description": "The newsletter that sent it. A `Ref` unless `newsletter` is named in\n`?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the run began handing recipients to the provider."
          },
          "completed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the run finished handing every recipient over. Null while it is\nstill going, and null for a run that broke before it could finish.\nThe counts below mean nothing until this is set.\n"
          },
          "recipient_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Audience size fixed at the moment the run started. The stable denominator for any rate a consumer computes."
          },
          "sent_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Recipients the provider accepted at dispatch. NOT confirmed deliveries: confirmation arrives later as `delivery.delivered`, one event per recipient."
          },
          "failed_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Recipients the provider rejected outright at dispatch."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the run was recorded, which is the same instant as\n`started_at` for every run Commune has made. The collection is\nordered by this rather than by `started_at`, which can be absent.\n"
          }
        }
      },
      "SendCompletedData": {
        "type": "object",
        "required": [
          "article_id",
          "send_id",
          "started_at",
          "completed_at",
          "recipient_count",
          "sent_count",
          "failed_count"
        ],
        "properties": {
          "article_id": {
            "type": "string",
            "format": "uuid"
          },
          "send_id": {
            "type": "string",
            "format": "uuid"
          },
          "started_at": {
            "type": "string",
            "format": "date-time"
          },
          "completed_at": {
            "type": "string",
            "format": "date-time"
          },
          "recipient_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Audience size fixed at the moment the run started. The stable denominator for any rate a consumer computes."
          },
          "sent_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Recipients the provider accepted at dispatch. NOT confirmed deliveries: confirmation arrives later as `delivery.delivered`, one event per recipient."
          },
          "failed_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Recipients the provider rejected outright at dispatch."
          }
        }
      },
      "SendCompletedEvent": {
        "title": "Send completed",
        "description": "A send run finished handing every recipient to the provider.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "send.completed"
              },
              "data": {
                "$ref": "#/components/schemas/SendCompletedData"
              }
            }
          }
        ]
      },
      "SendFailedData": {
        "type": "object",
        "required": [
          "article_id",
          "failure_reason"
        ],
        "properties": {
          "article_id": {
            "type": "string",
            "format": "uuid"
          },
          "send_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Null when the run broke before it was recorded, which is early enough that there is no run to point at."
          },
          "failure_reason": {
            "type": "string",
            "maxLength": 1000,
            "description": "Human-readable, truncated at 1000 characters on write. Meant for display, not for branching. Match on the topic, not this string."
          }
        }
      },
      "SendFailedEvent": {
        "title": "Send failed",
        "description": "A send run broke and the article was parked in failed.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "send.failed"
              },
              "data": {
                "$ref": "#/components/schemas/SendFailedData"
              }
            }
          }
        ]
      },
      "SenderVerifiedData": {
        "type": "object",
        "required": [
          "sender_id",
          "from_email",
          "domain",
          "kind",
          "verified_at"
        ],
        "properties": {
          "sender_id": {
            "type": "string",
            "format": "uuid"
          },
          "from_email": {
            "type": "string",
            "format": "email",
            "description": "Ready-to-use From address."
          },
          "from_name": {
            "type": "string"
          },
          "reply_to_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email"
          },
          "domain": {
            "type": "string",
            "description": "The sending domain that was verified."
          },
          "kind": {
            "type": "string",
            "enum": [
              "commune",
              "custom"
            ],
            "description": "`commune` is a Commune-provisioned subdomain, `custom` is a domain the creator owns and pointed at Commune."
          },
          "is_default": {
            "type": "boolean",
            "description": "Whether this is the newsletter's default sender, the one used when an article does not pin a specific one."
          },
          "verified_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SenderVerifiedEvent": {
        "title": "Sender verified",
        "description": "A sending identity passed verification and can now send.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "sender.verified"
              },
              "data": {
                "$ref": "#/components/schemas/SenderVerifiedData"
              }
            }
          }
        ]
      },
      "SubscriberCreatedData": {
        "type": "object",
        "required": [
          "subscriber_id",
          "email",
          "status",
          "resubscribed",
          "created_at"
        ],
        "properties": {
          "subscriber_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "status": {
            "type": "string",
            "enum": [
              "subscribed",
              "unsubscribed",
              "bounced",
              "complained",
              "pending"
            ]
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The Commune account behind this subscriber, when there is one. Null for an address imported from an ESP or CSV that never signed up."
          },
          "acquisition_source": {
            "type": [
              "string",
              "null"
            ],
            "description": "How a subscriber was imported, fixed when they were first recorded so a later migration between providers cannot relabel it. An ESP import stamps that provider's slug and a CSV upload stamps `csv`. Set only on those two paths, so it is null for a subscriber who subscribed in the Commune app, finished signup, or accepted an invitation. `commune` and `imported` appear on old subscribers labelled before this field existed; nothing writes either today."
          },
          "resubscribed": {
            "type": "boolean",
            "description": "True when an existing unsubscribed subscriber came back to `subscribed`, rather than a new subscriber being recorded."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the subscriber row was first created. On a resubscribe this is the ORIGINAL creation time, not the reactivation, which is `occurred_at`."
          }
        }
      },
      "SubscriberCreatedEvent": {
        "title": "Subscriber created",
        "description": "Someone became a subscriber, or an unsubscribed one came back.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "subscriber.created"
              },
              "data": {
                "$ref": "#/components/schemas/SubscriberCreatedData"
              }
            }
          }
        ]
      },
      "SubscriberStatusChangedData": {
        "type": "object",
        "description": "Body of `subscriber.status_changed`. Identifiers plus what describes the crossing, and not the insight itself: read `GET /newsletters/{newsletter}/insights` for the full scored row.",
        "required": [
          "subscriber_id",
          "user_id",
          "email",
          "previous_status",
          "status",
          "direction",
          "total_score",
          "velocity"
        ],
        "properties": {
          "subscriber_id": {
            "type": "string",
            "format": "uuid",
            "description": "The subscriber this score belongs to, the same identifier the other `subscriber.*` topics carry."
          },
          "user_id": {
            "type": "string",
            "description": "The Commune account the score is attributed to. Insights are keyed on the account rather than on the subscription, because half the signal is community activity that belongs to a person and not to a mailing list entry. Always present: an unscored subscriber cannot cross a boundary."
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "Where the newsletter reaches this reader, so a consumer can match the event against its own records without a second call. Resolved from the Commune account when the subscription row itself carries no address."
          },
          "previous_status": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/InsightStatus"
              },
              {
                "type": "null"
              }
            ],
            "description": "The status this reader held before the pass. Null the first time a reader is scored, which is a real crossing rather than missing data."
          },
          "status": {
            "$ref": "#/components/schemas/InsightStatus"
          },
          "direction": {
            "type": "string",
            "enum": [
              "promoted",
              "demoted"
            ],
            "description": "Which way along the ladder the reader moved, derived from the two statuses so a consumer does not have to hardcode their order. `promoted` the first time a reader is scored, because there is no earlier position they could have fallen from."
          },
          "total_score": {
            "type": "integer",
            "description": "The blended score at the moment of the crossing, summed across the community and the newsletter's email provider. It has no unit and no ceiling, and is meaningful only ranked against the other readers of the same newsletter."
          },
          "velocity": {
            "type": "string",
            "enum": [
              "rising",
              "cooling",
              "steady"
            ],
            "description": "The last fourteen days of points against the fourteen before them. Read it with `direction`: a demotion while `rising` means the audience around this reader moved faster, not that the reader slowed down."
          },
          "last_action_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the reader last did anything that earned points. The reader's own clock, not the scoring pass's, so it sits earlier than `occurred_at` by up to a full scoring interval. Null for a reader who has never acted."
          }
        }
      },
      "SubscriberStatusChangedEvent": {
        "title": "Subscriber insight status changed",
        "description": "A reader crossed a boundary in the newsletter's engagement ladder.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "subscriber.status_changed"
              },
              "data": {
                "$ref": "#/components/schemas/SubscriberStatusChangedData"
              }
            }
          }
        ]
      },
      "SubscriberTaggedData": {
        "type": "object",
        "title": "Subscriber tagged body",
        "description": "Body of `subscriber.tagged`. Which subscriber, which tag, and which way the membership moved.",
        "required": [
          "subscriber_id",
          "tag_id",
          "tag_name",
          "direction"
        ],
        "properties": {
          "subscriber_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "tag_id": {
            "type": "string",
            "format": "uuid"
          },
          "tag_name": {
            "type": "string",
            "description": "The tag's name at the time of the change."
          },
          "direction": {
            "type": "string",
            "enum": [
              "assigned",
              "removed"
            ],
            "description": "Whether the subscriber joined the segment or left it. Branch on this: it is the only thing that differs between the two changes this topic carries."
          }
        }
      },
      "SubscriberTaggedEvent": {
        "title": "Subscriber tag added or removed",
        "description": "A subscriber tag was applied or taken off.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "subscriber.tagged"
              },
              "data": {
                "$ref": "#/components/schemas/SubscriberTaggedData"
              }
            }
          }
        ]
      },
      "SubscriberUnsubscribedData": {
        "type": "object",
        "required": [
          "subscriber_id",
          "email",
          "reason",
          "unsubscribed_at"
        ],
        "properties": {
          "subscriber_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "reason": {
            "type": "string",
            "enum": [
              "self_service",
              "bounced",
              "complained"
            ],
            "description": "`self_service` is the reader opting out. `bounced` and `complained` are forced by delivery telemetry and arrive with the matching `delivery.*` event."
          },
          "unsubscribed_at": {
            "type": "string",
            "format": "date-time"
          },
          "article_send_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The send whose email the reader acted on, when the unsubscribe link carried it. Best-effort attribution: null on an in-app unsubscribe, a link without the marker, or a bounce or complaint."
          }
        }
      },
      "SubscriberUnsubscribedEvent": {
        "title": "Subscriber unsubscribed",
        "description": "A subscriber stopped being mailable.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "subscriber.unsubscribed"
              },
              "data": {
                "$ref": "#/components/schemas/SubscriberUnsubscribedData"
              }
            }
          }
        ]
      },
      "ThreadCreatedData": {
        "type": "object",
        "required": [
          "thread_id",
          "visibility",
          "content",
          "created_at"
        ],
        "properties": {
          "thread_id": {
            "type": "string",
            "format": "uuid",
            "description": "The thread itself, which is the opening message of the conversation rather than a reply within one."
          },
          "short_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Short public identifier used in thread URLs."
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri"
          },
          "author": {
            "$ref": "#/components/schemas/UserRef"
          },
          "content": {
            "type": "string",
            "maxLength": 5000,
            "description": "The opening message, capped at 5000 characters on write."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "public",
              "subscribers",
              "paid"
            ],
            "description": "`public` puts the thread on the global feed and only team members may set it. `subscribers` keeps it in the newsletter's own space. `paid` is reserved and unused."
          },
          "article_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Set when the thread is an article's discussion. Comments on an article are messages in this thread; there is no separate comment resource."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ThreadCreatedEvent": {
        "title": "Thread created",
        "description": "A new top-level thread was started in a newsletter's space.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "thread.created"
              },
              "data": {
                "$ref": "#/components/schemas/ThreadCreatedData"
              }
            }
          }
        ]
      },
      "ThreadPublishedData": {
        "type": "object",
        "title": "Thread published body",
        "description": "Body of `thread.published`. Which thread reached the feed, how it got there, and enough to render a feed row without a second call.",
        "required": [
          "thread_id",
          "visibility",
          "previous_visibility",
          "source",
          "published_at"
        ],
        "properties": {
          "thread_id": {
            "type": "string",
            "format": "uuid",
            "description": "The thread. The same id `thread.created` carried, so a consumer that stored that event can match on it directly."
          },
          "short_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Short public identifier used in thread URLs."
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri"
          },
          "author": {
            "$ref": "#/components/schemas/UserRef"
          },
          "visibility": {
            "type": "string",
            "const": "public",
            "description": "Always `public`. Present so a payload stays self-describing next to `thread.created`, which carries the same field and can carry other values."
          },
          "previous_visibility": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "subscribers",
              "paid",
              null
            ],
            "description": "What the thread was before. Null when it was created public, which is the case `source` also reports."
          },
          "source": {
            "type": "string",
            "enum": [
              "created_public",
              "visibility_changed"
            ],
            "description": "`created_public` for a thread a team member started public, which fires `thread.created` from the same write. `visibility_changed` for one that was featured later."
          },
          "published_by": {
            "allOf": [
              {
                "$ref": "#/components/schemas/UserRef"
              }
            ],
            "description": "The team member who put it on the feed. Named `published_by` rather than `actor` because the envelope already has an `actor`, and the two mean different things: this is a person, and that is the credential a change arrived under.\n\nNull in two cases. On the `created_public` path a thread was public from the start and nobody featured it. On a promotion made through this API there is no person to name, because a credential is not one; the envelope's `actor` carries the credential instead."
          },
          "article_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Set when the thread is an article's discussion rather than a standalone one."
          },
          "published_at": {
            "type": "string",
            "format": "date-time",
            "description": "`visibility_changed_at` on the `visibility_changed` path, the thread's `created_at` on the `created_public` path."
          }
        }
      },
      "ThreadPublishedEvent": {
        "title": "Thread published to the feed",
        "description": "A thread's visibility became public and it hit the global feed.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "thread.published"
              },
              "data": {
                "$ref": "#/components/schemas/ThreadPublishedData"
              }
            }
          }
        ]
      },
      "UserRef": {
        "type": [
          "object",
          "null"
        ],
        "title": "User reference",
        "description": "A pointer to a Commune account, enough to attribute and render something without a second call. Null when there is no account to point at, either because there never was one or because the account was deleted. Content outlives its author, so a null here is expected rather than a sign the reference was lost.",
        "required": [
          "user_id"
        ],
        "properties": {
          "user_id": {
            "type": "string"
          },
          "username": {
            "type": [
              "string",
              "null"
            ]
          },
          "display_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri"
          }
        }
      }
    }
  },
  "externalDocs": {
    "description": "Guides",
    "url": "https://usecommune.dev/guides"
  },
  "x-tagGroups": [
    {
      "name": "Newsletters and people",
      "tags": [
        "Newsletters",
        "Team",
        "Users",
        "Search"
      ]
    },
    {
      "name": "Publishing",
      "tags": [
        "Articles",
        "Highlights"
      ]
    },
    {
      "name": "Community",
      "tags": [
        "Threads",
        "Messages"
      ]
    },
    {
      "name": "Audience",
      "tags": [
        "Subscribers",
        "Subscriber tags"
      ]
    },
    {
      "name": "Insights",
      "tags": [
        "Engagement",
        "Metrics"
      ]
    },
    {
      "name": "Sending and domains",
      "tags": [
        "Sends",
        "Senders",
        "Website domains"
      ]
    },
    {
      "name": "Platform",
      "tags": [
        "Platform",
        "Event delivery",
        "Webhooks"
      ]
    }
  ]
}
