{
  "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### Removals\n\nNarrowing an enum and dropping a field from `required` are both breaking\nchanges, and this version has had one of each without a new release date,\nbecause the API is pre-release. Said here rather than left for a client\nto discover:\n\n* **`2026-10-10`: `paid` removed from `ThreadVisibility`.** A thread's\n  `visibility` is now `public` or `subscribers` and nothing else. The value\n  was a placeholder for a feature that was never built: no operation\n  accepted it, no thread was ever stored with it, and no event ever carried\n  it, so nothing a client has read or sent changes. `?visibility=paid` on\n  `GET /newsletters/{newsletter}/threads` now answers `400` for an unknown\n  parameter value instead of an empty page. `previous_visibility` on\n  `thread.published` narrows to `subscribers` or null for the same reason.\n\n* **`2026-10-13`: `article_id` and `send_id` no longer required on the\n  `delivery.*` payload.** Both are nullable now, and a new required\n  `source` says what caused the email: `article_send` carries the pair a\n  client already reads, `automation` carries `run_id` and `step_id`\n  instead. An article send was the only thing Commune could email when\n  those topics were written. Automations email too, and an automation\n  delivery has neither id. A client that dereferences `article_id`\n  unconditionally has to branch on `source` first. The reasoning, and\n  what was rejected, is on `DeliveryEventData`.\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"
    }
  },
  "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 hostname, so their newsletter lives at their address\nrather than at ours. The same prove you own this hostname flow as Senders,\npointed at the website rather than at the mail. Needs `settings`: a\nhostname is how the newsletter is configured, not how it sends.\n\nEach one serves one of two things, which `mode` names. `app` is the\nCommune newsletter page, with readers signed in; `site` is the creator's\nown pages from Creator websites, on an origin that never carries a\nsession. Changing which is the one write here. Adding a hostname and\nproving you own it happen in Commune itself, because both talk to the\ncertificate provider.\n"
    },
    {
      "name": "Creator websites",
      "description": "A creator's own website for their newsletter, built through this API by\nthem or by an agent speaking for them. There is no editor in Commune and\nthere are no templates: every page is free-form HTML, CSS and JavaScript\nthe creator owns outright, addressed by a path, and Commune hosts it.\n\nThree kinds of page. A `page` is any fixed path. A `newsletter` page is\nthe article list, and its path is where a reader clicking the newsletter's\nname or avatar inside Commune arrives. An `article` page is a URL pattern\nthat renders every article.\n\nFor the two that show articles, Commune injects a title, social tags, a\ncanonical URL, structured data and a preloaded copy of the article or the\nlist, server side, without touching the layout. The tag and date\nvisibility rules apply to every article it serves, the same as everywhere\nelse in this contract.\n\n**A creator website never carries a Commune session**, because one host\ncan hold a session or run creator JavaScript and never both: code on an\norigin with a session can call Commune with the visitor's cookie. So the\npages are served from their own origin, and the way a page reads this\nnewsletter's data is a `cmn_pk_` publishable key, which is also here. It\nis read-only, scoped to one newsletter, accepted only from that\nnewsletter's own website origins, and meant to be published.\n\nPages and the files they link to need `content`, which is the trust\ndrafting an article asks for. Publishable keys need `settings`, because a\nkey is a credential. The `newsletter` page needs `settings: write` on top\nof the content trust, since its path changes how the product links to the\nnewsletter rather than only what a visitor reads.\n"
    },
    {
      "name": "Automations",
      "description": "Event to action. An automation is a trigger and a graph: something happens,\nand a run walks the steps for the subscriber it happened to, sending,\nwaiting, moving tags and branching on a condition. Needs `sending`, because\nwhat an automation decides is that the list will receive something.\n\nEvery event Commune publishes is a trigger. `GET /automation-triggers` says\nwhich are about a subscriber and what each one's events carry, which is what\na condition can ask about.\n\nNothing an automation holds reaches anybody until it is turned on, which is\none operation of its own with every gate behind it.\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 26 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": []
    }
  ],
  "x-deferred": [
    {
      "resource": "user_newsletters",
      "scope": "partner",
      "reason": "The newsletters a person owns or is on the team of. Public one profile at a time, but served in bulk it maps the network. A Partner API candidate."
    },
    {
      "resource": "user_subscriptions",
      "scope": "partner",
      "reason": "The newsletters a person subscribes to. The reader side of the same network graph, so it waits for a Partner API with it."
    },
    {
      "resource": "user_activity",
      "scope": "public",
      "reason": "The threads, highlights and articles sub resources of a public profile. Each filters a collection that has its own operation."
    },
    {
      "resource": "user_settings",
      "scope": "reader",
      "reason": "Theme, contrast and the rest of a person's account preferences. Personal, and of no use to an integration."
    },
    {
      "resource": "notification_preferences",
      "scope": "reader",
      "reason": "Personal account settings."
    },
    {
      "resource": "push_subscriptions",
      "scope": "reader",
      "reason": "Per device push endpoints. Credential shaped, and personal."
    },
    {
      "resource": "newsletter_settings",
      "scope": "creator",
      "reason": "Chat permissions, physical address and editor defaults. Split from the core object so the public schema stays frozen, and deferred with the write surface it exists to serve."
    },
    {
      "resource": "invitations",
      "scope": "creator",
      "reason": "Carries invitee email addresses and single use tokens, and is write shaped. This version of the API is reads only."
    },
    {
      "resource": "esp_connections",
      "scope": "creator",
      "reason": "Holds provider OAuth tokens and API keys. The connection becomes readable without them; the credentials never do."
    },
    {
      "resource": "esp_imports",
      "scope": "creator",
      "reason": "Import and migration runs are long running writes against an outside provider. This version of the API is reads only."
    },
    {
      "resource": "esp_share_audiences",
      "scope": "creator",
      "reason": "The provider side allow list that decides what Commune ingests. Import configuration, not a resource an integration reads."
    },
    {
      "resource": "rss_authors",
      "scope": "creator",
      "reason": "The feed author to team member mapping. Import configuration, wired to one provider path."
    },
    {
      "resource": "newsletter_exports",
      "scope": "never",
      "reason": "An admin only operation, not part of the creator catalog."
    },
    {
      "resource": "article_drafts",
      "scope": "creator",
      "reason": "Commune's editor stores its own document format, and pinning it in a public contract would stop the editor evolving."
    },
    {
      "resource": "article_preview",
      "scope": "creator",
      "reason": "Renders an article to final email HTML. Worth exposing, and it would pin the merge tag engine and the block system while both are still moving."
    },
    {
      "resource": "article_compliance",
      "scope": "creator",
      "reason": "The pre send gate as a readable resource, answering \"would this send?\" without sending. Its blocker vocabulary is still growing, and freezing it now would freeze the gate; the send and schedule operations report the same refusals when they refuse."
    },
    {
      "resource": "article_move",
      "scope": "creator",
      "reason": "Moving a draft from one newsletter to another. A credential reads one newsletter, so both ends of the move cannot be named by one of them."
    },
    {
      "resource": "article_thread",
      "scope": "public",
      "reason": "An article's discussion, reachable as a sub resource. It is a thread and has an operation already; a second path to it is navigation."
    },
    {
      "resource": "article_comments",
      "scope": "public",
      "reason": "Dead table. An article's discussion is its chat thread, so the count is on `article.stats.comments` and the comments themselves are that thread's messages."
    },
    {
      "resource": "article_saved_event",
      "scope": "creator",
      "reason": "A topic for an article being saved or unsaved. Built alongside `article.liked` and `article.read` and then withdrawn before it shipped, on the ground that it is not the same kind of change they are.\n\nThose two ride a disclosure that already exists. A credential holding `insights: read` reads `GET /newsletters/{newsletter}/events` today, which names which subscriber viewed or liked which article, so a topic carrying the same facts tells a creator nothing they could not already fetch. A save has no counterpart anywhere: no entry in `EngagementEventType`, no tally on `Article.stats`, nothing in the product that shows a creator who saved what, and a row only its owner can read. The topic would therefore have been the first thing ever to tell a creator anything about saves, and the thing it told them would be who.\n\nThat is a decision about what readers are told is private, not a gap in the catalog, and it is deferred until that decision is made rather than shipped as a side effect of building its two neighbours. `article_saves` itself is untouched: `GET /saved-articles` still returns a person their own list."
    },
    {
      "resource": "article_shared_event",
      "scope": "creator",
      "reason": "A topic for an article being shared. Refused rather than queued, because Commune does not observe a share and cannot: the product hands the reader to the operating system's own share sheet, which reports nothing back, so the only shares that could ever be counted are the ones that begin with a button inside Commune, and even those end somewhere Commune cannot see.\n\nRead `share` in `EngagementEventType` as the record of an earlier attempt rather than as a signal that exists. The value is declared, the insight scores weight it, and the collection at `GET /newsletters/{newsletter}/events` will return one if it ever finds one. None of that makes a share observable, and a `share` row is not something any newsletter has.\n\nPublishing a topic for it would put a channel in this catalog that can never carry a message, which is worse than an absence: an absence is visible, and a silent channel reads as a quiet week."
    },
    {
      "resource": "article_read_state",
      "scope": "reader",
      "reason": "Per reader read and unread state, as a resource a client reads back and writes. Written on every read in the product, so exposing it invites the polling loop `article_views` is deferred for, on the same hot path. The `article.read` topic is not this resource arriving early: it is pushed rather than polled, which is the whole of what the objection was about, it reports one crossing per reader per article rather than a state a client can re-read, and it cannot be written."
    },
    {
      "resource": "article_views",
      "scope": "creator",
      "reason": "A write on every read in the product. Exposing it as a readable counter invites polling loops against a hot path. Still deferred after `article.read` landed, and not made redundant by it: that topic deliberately reports neither anonymous reads nor repeat visits, so it is not the counter and a consumer cannot build the counter out of it."
    },
    {
      "resource": "thread_demotion",
      "scope": "creator",
      "reason": "Taking a thread back off the global feed. Promoting one is an operation; the reverse has no topic and no considered answer to what a consumer already told about it should do."
    },
    {
      "resource": "article_schedule_cancelled_event",
      "scope": "creator",
      "reason": "A topic for a cancelled schedule. Cancelling is an operation; the event is not, for the reason directly above, and the article's own status is the authority until there is an answer."
    },
    {
      "resource": "thread_read_state",
      "scope": "reader",
      "reason": "Per user last read timestamps and mutes. A user token could hold it; a row names a thread, and handing one back would let an app walk into a private thread whose other participants consented to nothing."
    },
    {
      "resource": "thread_participants",
      "scope": "public",
      "reason": "Who spoke in a thread. Derivable from the thread's messages, which have an operation of their own."
    },
    {
      "resource": "moderation",
      "scope": "creator",
      "reason": "No moderation queue exists yet. An auditable log is worth having before write access rather than after it."
    },
    {
      "resource": "posts",
      "scope": "public",
      "reason": "Retired. Posts were folded into newsletter scoped chat threads, so the resource is `threads`, and modelling `posts` would put a dead stack into a contract with outside consumers."
    },
    {
      "resource": "post_replies",
      "scope": "public",
      "reason": "Retired with posts. A reply is a `message` in a thread."
    },
    {
      "resource": "reposts",
      "scope": "public",
      "reason": "Retired with posts, and never wired up: the internal surface returns a hardcoded zero."
    },
    {
      "resource": "community_member",
      "scope": "public",
      "reason": "One person's place in a community, addressable on its own. The person has an operation and the place carries nothing but a date, so a second path to it is navigation rather than a resource."
    },
    {
      "resource": "suppressions",
      "scope": "creator",
      "reason": "Bounces, complaints and unsubscribes as one list. The data is spread across two tables and there is no single surface to freeze yet."
    },
    {
      "resource": "audience_count",
      "scope": "creator",
      "reason": "Commune's subscriber records are a partial cache of an outside provider's list, so any total derived from them would misstate the audience. Ask the provider."
    },
    {
      "resource": "article_deliveries",
      "scope": "creator",
      "reason": "Per recipient send results, including bounces. Deferred until the send pipeline's own shape is stable enough to freeze."
    },
    {
      "resource": "article_send_stats",
      "scope": "creator",
      "reason": "Opens and clicks come from the sending provider on the provider's schedule, so a number read here would be stale in a way the contract could not describe."
    },
    {
      "resource": "send_links",
      "scope": "creator",
      "reason": "Click breakdown per destination URL. Clicks are recorded as events and never aggregated by destination, so the rollup does not exist."
    },
    {
      "resource": "deliverability",
      "scope": "creator",
      "reason": "Rolling bounce and complaint health against thresholds. Derivable, and nothing computes it today."
    },
    {
      "resource": "delivery_retries",
      "scope": "never",
      "reason": "Re-sending a send's failed recipients. Commune retries transient failures itself; what still fails is followed up by its team, because some of it may already have been delivered."
    },
    {
      "resource": "network_metrics",
      "scope": "never",
      "reason": "Internal analytics, computed on a cron for Commune's own use."
    },
    {
      "resource": "feed",
      "scope": "public",
      "reason": "Public threads, articles and highlights unioned into one stream. A discriminated union whose member shapes and ranking are still moving."
    },
    {
      "resource": "newsletter_feed",
      "scope": "public",
      "reason": "The same union scoped to one newsletter. Deferred with `feed`."
    },
    {
      "resource": "discover",
      "scope": "public",
      "reason": "An editorial surface whose ranking is still being tuned. Freezing its shape now would freeze an experiment."
    },
    {
      "resource": "notifications",
      "scope": "reader",
      "reason": "A person's notification inbox. A user token is the right credential for it; every item points at a thread, message or article somewhere, and serving those references needs the subject registry to answer what an app may follow them to."
    },
    {
      "resource": "notification_stream",
      "scope": "reader",
      "reason": "The server sent events channel behind a person's notification inbox. Deferred with `notifications` above, and additionally because a stream is not a Path Item. It was described in a separate AsyncAPI document for a while, on the grounds that a stream is not a Path Item, but it was never built and that document was the only thing that document held which this one could not express. Both are gone. A stream that is worth publishing to API keys brings the second document back with it."
    },
    {
      "resource": "reader_digest",
      "scope": "reader",
      "reason": "The weekly roundup as data rather than as an email. Personal."
    },
    {
      "resource": "creator_digest",
      "scope": "creator",
      "reason": "The creator side weekly. Every number in it is readable from the Metrics operations, so it is a rendering rather than a resource."
    },
    {
      "resource": "billing",
      "scope": "creator",
      "reason": "Plan, usage and payment method. A money surface deserves its own contract and its own review, not a corner of the read catalog."
    },
    {
      "resource": "media",
      "scope": "reader",
      "reason": "Upload only, and this version of the API is reads only."
    },
    {
      "resource": "render_email",
      "scope": "creator",
      "reason": "Renders arbitrary editor JSON to email safe HTML. Same reason as `article_preview`: it would pin the block system in a public contract."
    },
    {
      "resource": "oembed",
      "scope": "public",
      "reason": "oEmbed for articles, threads and highlights. A separately published spec with its own discovery rules, not a resource in this one."
    },
    {
      "resource": "api_key_mint",
      "scope": "never",
      "reason": "Minting a credential, which is refused rather than queued. A key that can mint keys outlives its own revocation: an intruder makes a second one, the creator revokes the first, and nothing they did stopped anything. A key is minted by a signed in person in Commune's settings, where the secret is shown once. Listing and revoking keys are operations above."
    },
    {
      "resource": "oauth",
      "scope": "public",
      "reason": "The authorize and token endpoints a third party app uses. Built, and on the authorization server rather than here: they live in the Commune app, because issuing a credential means showing a signed-in person a screen and this service has no sessions. `GET /.well-known/oauth-protected-resource` is how a client finds them, and the flow is walked through in full at `usecommune.dev/use-cases/build-an-integration`. Not to be confused with `oauth_protected_resource` below: that one is this service saying where tokens for *it* come from."
    },
    {
      "resource": "oauth_protected_resource",
      "scope": "public",
      "reason": "`GET /.well-known/oauth-protected-resource`, the RFC 9728 metadata document a client fetches after a `401` to find the authorization server. It is not a Commune resource and it is not versioned by `Commune-Version`: its shape is fixed by the RFC, it is the same bytes for every caller, and it is unauthenticated because discovery is what a caller does when it has no usable credential. The authorization server it names is not this API; it is the Commune app itself, where the creator's session and the consent screen already are."
    },
    {
      "resource": "spec_documents",
      "scope": "public",
      "reason": "This document, served as JSON and as YAML, each with `?version=`, `?profile=` and `?lang=`. Describing itself inside itself is circular."
    },
    {
      "resource": "site_data",
      "scope": "public",
      "reason": "`GET /site/articles` and `GET /site/articles/{article}`, the two reads a creator website's own JavaScript makes with a `cmn_pk_` publishable key. Served, and deliberately not operations here, because the credential is not one this document can describe: it is read-only, bound to one newsletter by its own row, accepted only from that newsletter's own website origins, rate limited by a number on the row, and **meant to be published**, since it ships inside page source.\n\nThree consequences, each of which would be a special case inside this document rather than a description of it. A scope here is `family:level`, and this credential holds no family at any level. Every other response from this service carries `Access-Control-Allow-Origin: *`, which is safe because a credential in a header is one a browser will not attach on its own; this one echoes the single origin it matched and varies on it, because the browser is the caller. And the budget is the key's own rather than the per-credential budgets `/rate-limit` reports.\n\nWhat they return is the same two queries the websites origin serves, with the same visibility gates, which is the property worth having: a newsletter's website and a script reading that website's data must show the same articles. The surface a creator reads is the website SDK's own documentation, which is where a browser-side credential belongs.\n\nMinting, rotating and revoking one of these keys **are** operations above, under Creator websites, because that is an authenticated creator acting on their own configuration."
    },
    {
      "resource": "mcp_server",
      "scope": "creator",
      "reason": "`POST /mcp`, the Model Context Protocol endpoint. It is a JSON-RPC envelope over the operations declared above rather than a resource of its own: one tool is one Arazzo workflow, and every step of every workflow is one of these operations, dispatched through the same gateway with the caller's own key. Declaring the envelope here would publish a second, untyped way to call operations that are already typed, and OpenAPI cannot describe what a `tools/call` body may contain without restating all twenty argument schemas the manifest already carries."
    },
    {
      "resource": "webhooks",
      "scope": "never",
      "reason": "Inbound endpoints for outside services, authenticated by signature rather than by key. The outbound direction is not this resource: the events a consumer receives are the generated `webhooks` block."
    },
    {
      "resource": "cron",
      "scope": "never",
      "reason": "Internal scheduled jobs, guarded by a shared secret."
    },
    {
      "resource": "admin",
      "scope": "never",
      "reason": "Commune staff surface. Never public."
    }
  ],
  "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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      },
      "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"
          }
        }
      },
      "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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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 hostnames attached to this newsletter, each saying\nwhich Commune website origin answers for it. Needs `settings: read`.\n\n**What this returns has been described more narrowly than it behaved.**\nIt used to say \"the domains pointed at this newsletter's Commune site\",\nand that was the same set only while a hostname could serve nothing\nelse. A hostname can now serve the creator's own pages instead, this\ncollection has always returned every row of the table, and `mode` on\neach entry is what makes \"every row\" readable rather than misleading.\nUse `?mode=app` for the old reading of this list, and nothing filters\nunless you ask.\n\nTwo independent things have to be true for a hostname to work, and they\nare reported separately. `verification_status` says whether ownership\nwas proved and a certificate issued. `routing_ok` says whether the\nhostname actually resolves to Commune, which a certificate says nothing\nabout.\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"
          },
          {
            "name": "mode",
            "in": "query",
            "required": false,
            "description": "Return only hostnames serving this. Omit for all of them.\n",
            "schema": {
              "$ref": "#/components/schemas/DomainMode"
            }
          }
        ],
        "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 hostnames attached to this\nnewsletter, most recently added first, whatever each\none serves. Empty for a newsletter that reads on\nCommune's own domain, which is the default rather than\nan error. `mode` says which Commune website origin\nanswers for an entry, and a newsletter holds at most\none hostname in each mode. Two independent things have\nto be true for one to serve, and each entry reports\nthem separately: `verification_status` is about\nownership and the certificate, `routing_ok` is about\nwhether the hostname 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",
                          "mode": "app",
                          "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",
                          "mode": "site",
                          "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"
          }
        }
      }
    },
    "/domains/{domain}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/DomainPath"
        }
      ],
      "get": {
        "operationId": "getDomain",
        "summary": "Retrieve a website domain",
        "description": "Read one of the creator's hostnames: which Commune website origin\nanswers for it, the records to publish, and the last reachability\nprobe. Needs `settings: read`.\n\nResolves whatever the hostname serves. `mode` says which, and a\nhostname serving the creator's own pages resolves here exactly as one\nserving the Commune newsletter page does: this is the operation that\nanswers \"what is this hostname doing\", and an answer that omitted half\nthe hostnames would be the wrong one.\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"
          }
        }
      },
      "patch": {
        "operationId": "updateDomain",
        "summary": "Change what a website domain serves",
        "description": "Hands one of your hostnames between Commune's two website origins, or\ntakes it back. Needs `settings: write`, and is a write.\n\n`mode` is the only property this changes and the only one it accepts.\n`app` is the Commune newsletter page, which is the default every\nhostname starts on and is where readers sign in, chat and highlight.\n`site` is your own pages, built through Creator websites, served from an\norigin that never carries a Commune session.\n\n**One host can carry a session or run your JavaScript, never both.**\nThat is why this is a mode rather than two features on one hostname, and\nwhy switching to `site` makes Commune clear the cookies and storage it\nleft on that origin: once your own code runs there, anything Commune\nkept would be readable by it. Switching back clears what your pages\nleft, for the mirror image of the same reason.\n\nA newsletter holds at most one hostname in each mode. Asking for a mode\nanother of your hostnames already holds answers `409`; free it first.\n\n**What this does not do.** Nothing is reprovisioned: the certificate,\nthe records proving you own the hostname and the target your DNS points\nat are all untouched, because what changes is which Commune origin\nanswers rather than who owns the name. Adding a hostname and proving\nownership of it are not operations here at all.\n\nPublishes no event.\n",
        "tags": [
          "Website domains"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DomainUpdateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The domain as it stands. `mode` is what you asked for, whether or\nnot it had to change: asking for the mode a hostname is already on\nsucceeds and alters nothing, which is the answer a retry deserves.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Domain"
                }
              }
            }
          },
          "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}/website-pages": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterSitePages",
        "summary": "List a newsletter's website pages",
        "description": "Every live page of this newsletter's website, drafts included, in path\norder. Needs `content: read`.\n\nThe documents are not here. A page holds up to 2MB of HTML and a website\nholds many, so this is the index: each entry carries its address, its\nstate, the weight of its document and where to look at it. Retrieve a\nwebsite page for the document itself.\n\nA draft has no public address at all, so its `url` is null and its\n`preview_url` is a signed link that opens that one page and nothing\nelse. An `article` page is a URL pattern rather than a URL, so its `url`\nis null whatever its state.\n\nA deleted page is not here at all, and its path is free for another page\nto claim.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "kind",
            "in": "query",
            "required": false,
            "description": "Return only pages of this kind. Repeat the parameter to accept\nseveral.\n",
            "schema": {
              "$ref": "#/components/schemas/SitePageKind"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Return only pages in this state. Omit for both.",
            "schema": {
              "$ref": "#/components/schemas/SitePageStatus"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of website pages.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "The newsletter's website pages in path order, drafts\nincluded, without their documents. Empty for a\nnewsletter with no website, which is the default\nrather than an error. An entry's `url` is where it is\nserved, and is null for a draft, for a page Commune\nhas taken down, for an `article` pattern, and for a\nnewsletter whose website hostname is not live yet.\n",
                          "items": {
                            "$ref": "#/components/schemas/SitePageSummary"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "aSmallSite": {
                    "summary": "A live article list, a published page and a draft.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "site_page_summary",
                          "id": "2b7f9c10-4d5e-4a6b-8c7d-9e0f1a2b3c4d",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "kind": "newsletter",
                          "path": "/",
                          "status": "published",
                          "blocked": false,
                          "meta": {
                            "title": "The Weekly"
                          },
                          "byte_size": 14208,
                          "url": "https://blog.example.org",
                          "preview_url": null,
                          "published_at": "2026-10-01T09:00:00Z",
                          "created_at": "2026-09-30T18:12:44Z",
                          "updated_at": "2026-10-05T11:03:19Z"
                        },
                        {
                          "object": "site_page_summary",
                          "id": "3c8a0d21-5e6f-4b7c-9d8e-0f1a2b3c4d5e",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "kind": "page",
                          "path": "/about",
                          "status": "published",
                          "blocked": false,
                          "meta": {},
                          "byte_size": 5311,
                          "url": "https://blog.example.org/about",
                          "preview_url": null,
                          "published_at": "2026-10-01T09:04:00Z",
                          "created_at": "2026-10-01T08:58:10Z",
                          "updated_at": "2026-10-01T09:04:00Z"
                        },
                        {
                          "object": "site_page_summary",
                          "id": "4d9b1e32-6f70-4c8d-8e9f-1a2b3c4d5e6f",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "kind": "article",
                          "path": "/posts/:article",
                          "status": "draft",
                          "blocked": false,
                          "meta": {},
                          "byte_size": 9044,
                          "url": null,
                          "preview_url": "https://blog.example.org/?preview=eyJwIjoiNGQ5YjFlMzIifQ.Zm9vYmFy",
                          "published_at": null,
                          "created_at": "2026-10-06T14:20:02Z",
                          "updated_at": "2026-10-06T14:20:02Z"
                        }
                      ],
                      "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"
          }
        }
      },
      "post": {
        "operationId": "createSitePage",
        "summary": "Create a website page",
        "description": "Writes a page of this newsletter's website. Needs `content: write`, and\nis a write.\n\nA page is free-form HTML, CSS and JavaScript that you own outright.\nThere is no template, no enforced layout and no deploy step: an edit to\na published page is live the moment it commits.\n\n`kind` decides what `path` means and cannot be changed afterwards.\n\n* `page` is a fixed path. Anything you like: `/about`, `/subscribe`.\n* `newsletter` is the article list, and its path is where a reader\n  clicking the newsletter's name or avatar inside Commune arrives. A\n  newsletter may have one.\n* `article` is a URL **pattern** that renders every article. It has to\n  contain `:article`, and may also use `:year`, `:month`, `:day` and\n  `:id`. A token is a whole path segment. An unknown token answers\n  `422`; two patterns that could both serve one URL answer `409`.\n\nA fixed path always beats a pattern, so `/about` and `/:article` coexist\nand `/about` is the page.\n\n`status` defaults to `draft`, which has no public address: it is\nreachable only through the signed `preview_url` this returns. Publish a\nwebsite page puts it live.\n\n**No versioning.** Commune keeps the page you last wrote and nothing\nelse, so keep your site in a repository of your own.\n\nA document over 2MB answers `422`. Move images, fonts and shared\nstylesheets or scripts into Store a website asset and link them.\n\nPublishes no event.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SitePageCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The page, with its document.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SitePage"
                },
                "examples": {
                  "aDraftAboutPage": {
                    "summary": "A draft, with the signed link that opens it.",
                    "value": {
                      "object": "site_page",
                      "id": "3c8a0d21-5e6f-4b7c-9d8e-0f1a2b3c4d5e",
                      "newsletter": {
                        "object": "newsletter",
                        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                      },
                      "kind": "page",
                      "path": "/about",
                      "status": "draft",
                      "blocked": false,
                      "meta": {
                        "title": "About The Weekly"
                      },
                      "byte_size": 96,
                      "url": null,
                      "preview_url": "https://blog.example.org/about?preview=eyJwIjoiM2M4YTBkMjEifQ.Zm9vYmFy",
                      "published_at": null,
                      "created_at": "2026-10-08T10:15:00Z",
                      "updated_at": "2026-10-08T10:15:00Z",
                      "html": "<!doctype html><html><head><title>About</title></head><body><h1>About</h1></body></html>"
                    }
                  }
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/website-pages/{page}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/SitePagePath"
        }
      ],
      "get": {
        "operationId": "getSitePage",
        "summary": "Retrieve a website page",
        "description": "One page of a creator website, with its document. Needs `content: read`.\n\nThis is how an agent that has lost its local copy reads a page back.\n`html` is exactly what was written, byte for byte: Commune stores it\nunchanged and injects search metadata when it serves the page rather\nthan when it keeps it.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SitePage"
                }
              }
            }
          },
          "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"
          }
        }
      },
      "patch": {
        "operationId": "updateSitePage",
        "summary": "Change a website page",
        "description": "Rewrites the document, the metadata or the address of one page. Needs\n`content: write`, and is a write.\n\nSend at least one of `path`, `html` and `meta`. An omitted property is\nleft alone; `html` replaces the whole document, because a page is one\ndocument and there is nothing in it Commune understands well enough to\npatch.\n\n`kind` and `status` are not here. The kind decides whether `path` is a\npath or a pattern, so changing it would make a valid page invalid in one\nstatement; the state has two operations of its own, so that an edit to\nyour text cannot put a page live by accident.\n\nIf the page is published the change is live immediately. Changing an\n`article` pattern keeps the old URLs working: a request to one of them\nis redirected to the article's new address.\n\nPublishes no event.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SitePageUpdateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The page as it stands after the change.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SitePage"
                }
              }
            }
          },
          "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": "deleteSitePage",
        "summary": "Delete a website page",
        "description": "Takes a page off the website and frees the address it answered at. Needs\n`content: write`, and is a write.\n\nThe document is kept, so deleting the wrong page is recoverable by\nCommune, but there is no operation here that undoes it: write the page\nagain. The address is available immediately, which is the half worth\nknowing before you call this, because a pattern that was shadowed by\nthis page starts serving that URL.\n\nA page Commune has taken down cannot be deleted: its address stays\nreserved while that stands, so that it cannot be re-registered.\n\nAnswers the page as it was, so the response is a record of what was\nremoved rather than an empty body.\n\nPublishes no event.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The page that was deleted, as it last stood.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SitePage"
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/website-pages/{page}/publish": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/SitePagePath"
        }
      ],
      "post": {
        "operationId": "publishSitePage",
        "summary": "Publish a website page",
        "description": "Gives a page a public address. Needs `content: write`, and is a write.\n\nLive immediately: there is no deploy step and no build. A `newsletter`\npage going live is also the moment Commune starts linking the\nnewsletter's name and avatar at your website instead of at its page on\nCommune.\n\n`published_at` is stamped the first time and never moved, so\nunpublishing and publishing again keeps the date the page first went up.\nPublishing a page that is already published succeeds and changes\nnothing.\n\nPublishes no event.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The page, published, with the address it now answers at.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SitePage"
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/website-pages/{page}/unpublish": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/SitePagePath"
        }
      ],
      "post": {
        "operationId": "unpublishSitePage",
        "summary": "Unpublish a website page",
        "description": "Takes a page back to a draft. Needs `content: write`, and is a write.\n\nIts address stops answering at once and only the signed `preview_url`\nreaches it. `published_at` is left where it was, because it records when\nthe page first went live rather than whether it is live now.\n\nUnpublishing the `newsletter` page takes the whole website's article\nlist away, and Commune goes back to linking the newsletter's name and\navatar at its page on Commune. Nothing else on the site is affected.\n\nUnpublishing a draft succeeds and changes nothing.\n\nPublishes no event.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The page, back to a draft, with its preview link.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SitePage"
                }
              }
            }
          },
          "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}/website-assets": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "post": {
        "operationId": "createSiteAsset",
        "summary": "Store a website asset",
        "description": "Hands back a one-time URL to upload a file to, and the permanent address\nit will be served at. Needs `content: write`, and is a write.\n\nFor the things a page links to rather than contains: images, video,\naudio, fonts, and shared stylesheets or scripts. Put their URLs in your\nHTML; nothing here changes a page.\n\nThe bytes go straight from you to storage and never pass through this\nAPI or through a model, so `url` answers `404` until you have sent them.\nSend them with a `PUT` carrying the `Content-Type` you named, before\n`upload.expires_at`.\n\n`content_type` is what the upload is minted for, and storage enforces it\non the `PUT` itself: a file of a different type is refused there rather\nthan stored wrongly. `image/svg+xml` is not accepted, because an SVG\nnavigated to directly runs its own script on the storage origin, which\nevery creator's assets share.\n\nStorage not being configured on this deployment answers `503` rather\nthan accepting a file Commune cannot keep.\n\nPublishes no event.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "content:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SiteAssetCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The asset, and where to send the file. `byte_size` and `uploaded_at`\nare null until the bytes arrive.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteAsset"
                },
                "examples": {
                  "aFontToUpload": {
                    "summary": "A web font, waiting for its bytes.",
                    "value": {
                      "object": "site_asset",
                      "id": "5e0c2f43-7081-4d9e-9f01-2b3c4d5e6f70",
                      "newsletter": {
                        "object": "newsletter",
                        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                      },
                      "filename": "inter.woff2",
                      "content_type": "font/woff2",
                      "url": "https://assets.usecommune.com/site-assets/7d3f1c02/1760000000-a1b2c3d4-inter.woff2",
                      "byte_size": null,
                      "uploaded_at": null,
                      "created_at": "2026-10-08T10:20:00Z",
                      "upload": {
                        "url": "https://upload.usecommune.com/site-assets/signed/abc123",
                        "method": "PUT",
                        "headers": {
                          "Content-Type": "font/woff2"
                        },
                        "max_bytes": 52428800,
                        "expires_at": "2026-10-08T12:20: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"
          }
        }
      }
    },
    "/newsletters/{newsletter}/publishable-keys": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterPublishableKeys",
        "summary": "List a newsletter's publishable keys",
        "description": "The read-only keys this newsletter's website page source may carry, live\nones first and newest first. Needs `settings: read`.\n\nNo secret and no digest: a secret is shown once, by the call that minted\nit, and is never readable again. `key_prefix` is what you recognise a\nkey by, and `last_used_at` is how you tell which key your deployed pages\nare actually calling before turning the other one off.\n\nRevoked keys are hidden unless you ask for them. A key that was abused\nhas to stay nameable, which is why revocation is a stamp rather than a\ndeletion.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          },
          {
            "name": "include_revoked",
            "in": "query",
            "required": false,
            "description": "`true` also returns keys that have been turned off. They sort below\nthe live ones.\n",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of publishable keys.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "The newsletter's publishable keys, live ones first and\nnewest first, each without its secret. Empty for a\nnewsletter whose website reads no Commune data, which\nis a website Commune serves perfectly well. `origins`\non each entry is the list the key is accepted from,\nwhich always contains the newsletter's own website\norigins whether or not `allowed_origins` names any.\n",
                          "items": {
                            "$ref": "#/components/schemas/PublishableKey"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "oneLiveKey": {
                    "summary": "One key, in use.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "publishable_key",
                          "id": "6f1d3054-8192-4e0f-8012-3c4d5e6f7081",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "name": "Website",
                          "key_prefix": "cmn_pk_7Qa2Lk9x",
                          "secret": null,
                          "allowed_origins": [
                            "http://localhost:5173"
                          ],
                          "origins": [
                            "https://blog.example.org",
                            "https://the-weekly.communepages.com",
                            "http://localhost:5173"
                          ],
                          "rate_limit_per_minute": 600,
                          "monthly_request_quota": null,
                          "created_at": "2026-10-02T08:00:00Z",
                          "last_used_at": "2026-10-08T09:58:12Z",
                          "revoked_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"
          }
        }
      },
      "post": {
        "operationId": "createPublishableKey",
        "summary": "Create a publishable key",
        "description": "Mints a read-only key your website's page source can read this\nnewsletter's public data with. Needs `settings: write`, and is a write.\n\n**The secret is in this response and nowhere else.** Commune keeps only\na digest of it, so no later call returns it; a key you have lost is a\nkey you rotate.\n\nIt is `cmn_pk_`-prefixed rather than `cmn_sk_` because it is meant to be\npublic: it goes in HTML that anybody can read. What stops that being a\nproblem is what the key can do, which is read what this newsletter has\nalready published, from this newsletter's own website origins, inside a\nrate limit. Never an email address, never a draft, never a tag-scoped\narticle, and never another newsletter.\n\n`allowed_origins` are **extra** origins, for a development machine or a\nstaging host. The newsletter's own website origins are always accepted\nand do not need listing, which is why the empty default means \"the real\nwebsite and nowhere else\" rather than \"nowhere\".\n\nThis is the one place in this API where a credential mints a credential.\nAn API key cannot mint an API key, because a key that could would\noutlive its own revocation. Neither half of that holds here: this key\ncan mint nothing, and it reaches one newsletter's published rows by its\nown row rather than by a grant anybody could widen.\n\nPublishes no event.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PublishableKeyCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The key, with its secret. Store it now: this is the only response\nthat carries it.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublishableKey"
                },
                "examples": {
                  "freshlyMinted": {
                    "summary": "A new key, secret included once.",
                    "value": {
                      "object": "publishable_key",
                      "id": "7081e165-92a3-4f10-9123-4d5e6f708192",
                      "newsletter": {
                        "object": "newsletter",
                        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                      },
                      "name": "Website",
                      "key_prefix": "cmn_pk_7Qa2Lk9x",
                      "secret": "cmn_pk_7Qa2Lk9xBv3mNd8sTr4wYc6zQh1jFp5K",
                      "allowed_origins": [],
                      "origins": [
                        "https://blog.example.org",
                        "https://the-weekly.communepages.com"
                      ],
                      "rate_limit_per_minute": 600,
                      "monthly_request_quota": null,
                      "created_at": "2026-10-08T10:30:00Z",
                      "last_used_at": null,
                      "revoked_at": 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"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/publishable-keys/{publishable_key}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/PublishableKeyPath"
        }
      ],
      "delete": {
        "operationId": "revokePublishableKey",
        "summary": "Revoke a publishable key",
        "description": "Turns a key off. Needs `settings: write`, and is a write.\n\nEvery request made with it is refused from then on, so any page still\ncarrying it stops being able to read this newsletter's data. Deploy the\nreplacement before calling this.\n\nThe row survives and keeps its name, its prefix and the date it was\nturned off, because the question asked after an incident is \"which key\nwas that\", and a row that vanished on revocation cannot answer it.\nRevoking an already revoked key succeeds and keeps the original date.\n\nThere is no un-revoke, for the reason there is none for an API key: a\nrevocation that could be undone would let whoever found the key put it\nback.\n\nPublishes no event.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The key, turned off.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublishableKey"
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/publishable-keys/{publishable_key}/rotate": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/PublishableKeyPath"
        }
      ],
      "post": {
        "operationId": "rotatePublishableKey",
        "summary": "Rotate a publishable key",
        "description": "Mints a replacement carrying the same name, origins, rate limit and\nquota as the key you named. Needs `settings: write`, and is a write.\n\n**The old key keeps working.** That is the whole point of rotating\nrather than revoking: a publishable key lives in deployed HTML, so the\norder has to be mint, deploy, then revoke. Two live keys in between is\nthe normal case rather than a violation, and `last_used_at` on each is\nhow you tell when the old one has stopped being called.\n\nRevoke a publishable key is the third step, and nothing here does it for\nyou: Commune cannot know when your pages have gone out.\n\nRotating a revoked key is allowed and is the ordinary way to replace one\nyou have lost: it copies the settings and mints a new secret, and the\nrevoked key stays revoked.\n\nAs with minting, the secret is in this response and nowhere else.\n\nPublishes no event.\n",
        "tags": [
          "Creator websites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "settings:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "201": {
            "description": "The new key, with its secret. The key you named is untouched and\nstill live.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublishableKey"
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/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, evaluated at\nthe public audience. An article stamped with an audience, an article\ndated in the future, a draft, and a thread that is not public are never\nreturned, so search cannot be used to see around a permission.\n\n**Search returns public content only, whatever the credential is.**\nEverything in a result is something a logged out visitor could already\nread on the web. That is deliberately narrower than what a credential\ncan reach directly: a key granted `content:read` on its own newsletter\nstill does not find that newsletter's audience-stamped issues or its\nprivate rooms here. Read those through the article and thread\noperations, which evaluate the audience against the credential.\nSearching your own restricted content is not part of this operation.\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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      },
      "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"
          }
        }
      },
      "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"
          }
        }
      }
    },
    "/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"
          }
        }
      },
      "post": {
        "operationId": "createSubscriber",
        "summary": "Add a subscriber",
        "description": "Puts an address on this newsletter's list. Needs `audience: write`, and\nis a write.\n\n**This is not an import, and it is the call a welcome sequence listens\nfor.** It publishes `subscriber.created`, which is the topic automations\ntrigger on, so every address added here enters whatever welcome sequence\nthe newsletter has running. An import and the daily provider sync\ndeliberately publish no such event, so a list migrated in never gets\nwelcomed; calling this in a loop over a list you already have is how you\nundo that, one email at a time. Move a list with an import.\n\n**An address already on the list is not an error.** A signup form cannot\nknow whether the person in front of it subscribed last year, so:\n\n  * Already `subscribed`: nothing changes, nothing is published, and the\n    answer is `200` with the subscriber as it stands.\n  * `unsubscribed` or `bounced`: the subscription is reactivated and the\n    answer is `200`. This publishes `subscriber.created` with\n    `resubscribed: true`, which automations treat as **not** a new\n    subscriber, so nobody is welcomed twice.\n  * `complained`: refused with `422`. The reader told a mailbox provider\n    this newsletter is spam, and only the reader can undo that by\n    subscribing again themselves.\n\nA new subscriber answers `201`. So the status says whether a\nsubscription was created, and the body says what it now is either way.\n\nThe same person is one subscriber however their row is shaped. An\naddress that belongs to a Commune account is attached to that account,\nso the subscription follows the address on it, and an address Commune\nhas never seen is stored on the subscription itself, lowercased. Two\ncalls differing only in case are therefore one subscriber rather than\ntwo copies of one person receiving every article twice.\n\n`source` comes back `commune`: the subscription was made on Commune and\nwas not mirrored off a provider's list. `tags` is empty, because a tag\ndecides what somebody can read and is applied by its own call.\n\n**On a newsletter connected to an outside provider this writes\nCommune's record and not the provider's.** Nothing is added to Kit,\nMailchimp or anywhere else, so add the contact there too if that list is\nyour source of truth. The daily sync only ever adds, so a subscriber\nwritten here is not removed by it.\n",
        "tags": [
          "Subscribers"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubscriberCreateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The address was already on the list. Reactivated if it was inactive,\nleft alone if it was already subscribed.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Subscriber"
                },
                "examples": {
                  "reactivated": {
                    "summary": "Somebody who had unsubscribed, back on the list.",
                    "value": {
                      "object": "subscriber",
                      "id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "newsletter": {
                        "object": "newsletter",
                        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                      },
                      "user": null,
                      "email": "reader@example.com",
                      "status": "subscribed",
                      "source": "commune",
                      "tags": [],
                      "created_at": "2026-08-26T12:20:05Z",
                      "synced_at": null
                    }
                  }
                }
              }
            }
          },
          "201": {
            "description": "A subscription that did not exist before.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Subscriber"
                },
                "examples": {
                  "added": {
                    "summary": "An address Commune had not seen on this newsletter.",
                    "value": {
                      "object": "subscriber",
                      "id": "55667788-99aa-4bb2-c3d4-e5f607182930",
                      "newsletter": {
                        "object": "newsletter",
                        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                      },
                      "user": null,
                      "email": "new.reader@example.com",
                      "status": "subscribed",
                      "source": "commune",
                      "tags": [],
                      "created_at": "2026-10-11T08:00:00Z",
                      "synced_at": 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"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/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"
          }
        }
      },
      "patch": {
        "operationId": "updateSubscriber",
        "summary": "Update a subscriber",
        "description": "Sets whether this subscriber is on the list. Needs `audience: write`,\nand is a write.\n\n**`status` is the whole of what this writes**, and only two of its five\nvalues can be sent. `bounced` and `complained` are verdicts a mailbox\nprovider reached, so setting one would be asserting somebody else's\nanswer on their behalf, and `pending` belongs to a confirmation flow\nnothing in this API starts. The address is not writable either: it is\nthe subscription's identity, and on a subscriber with a Commune account\nbehind them it lives on that account rather than here. Add the right\naddress and delete the wrong one.\n\nBoth directions publish:\n\n  * To `unsubscribed`: `subscriber.unsubscribed` with\n    `reason: removed`, which is the reason that means the newsletter\n    did it rather than the reader. The row survives, so bounce and\n    complaint history is kept and `GET` goes on resolving them.\n  * To `subscribed`: `subscriber.created` with `resubscribed: true`,\n    exactly as a reader coming back publishes. Automations treat it as\n    not a new subscriber, so nobody is welcomed again.\n\nSetting the status it already has succeeds, changes nothing and\npublishes nothing. Setting `subscribed` on a `complained` subscriber is\nrefused with `422`: only the reader can undo a spam complaint, by\nsubscribing again themselves.\n\nUnlike Add a subscriber, this does not change `source`. A subscription\nimported from a provider and reactivated here is still an imported\nsubscription, because what moved was one column of an existing row and\nnot a new subscription being made.\n",
        "tags": [
          "Subscribers"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubscriberUpdateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The subscriber, as it now stands.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Subscriber"
                },
                "examples": {
                  "takenOffTheList": {
                    "summary": "A subscriber the newsletter unsubscribed.",
                    "value": {
                      "object": "subscriber",
                      "id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "newsletter": {
                        "object": "newsletter",
                        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                      },
                      "user": null,
                      "email": "reader@example.com",
                      "status": "unsubscribed",
                      "source": "imported",
                      "tags": [],
                      "created_at": "2026-08-26T12:20:05Z",
                      "synced_at": 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"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "delete": {
        "operationId": "deleteSubscriber",
        "summary": "Erase a subscriber",
        "description": "Erases this subscription. Needs `audience: write`, is a write, and\n**requires a request body confirming the erasure.**\n\n**This is an erasure, not an unsubscribe, and it cannot be undone.** The\nsubscriber record goes, and everything keyed on it goes with it: the\ntags they held, their per-article delivery rows, and any automation run\nthat named them, along with the automation emails those runs sent. The\nnewsletter's send totals are counters and do not move, but \"did this\nperson open that article\" stops having an answer. It is the call for\nsomebody exercising a right to erasure.\n\n**To stop mailing somebody, set their status instead**\n(`PATCH /subscribers/{subscriber}` with\n`{\"status\": \"unsubscribed\"}`). That keeps the record, so a bounce or a\ncomplaint on the address is remembered and Commune does not mail it\nagain by accident, and a reader who comes back reuses the same row. It\nis also reversible, and this is not.\n\n## The confirmation\n\nSend `{\"confirm\": \"erase_subscriber_and_history\"}`. Without it, or with\nany other value, the request is refused `400` with `param` set to\n`confirm` and `allowed_values` holding the one accepted string, and\nnothing is erased: the subscriber, the tags they hold and their delivery\nhistory are all exactly as they were.\n\nThe refusal is not a formality to route around. It exists because this\ncall and `PATCH /subscribers/{subscriber}` read alike and do not undo\nalike, so an integration reaching for \"remove this subscriber\" lands\nhere by default and loses the record of everything that was ever sent to\nthat person. If what you want is for the newsletter to stop reaching\nsomebody, the status write is the call, and it is the one to reach for\nwhen you are not sure.\n\n**A change in the contract as of this version, with no compatibility\nfallback.** A `DELETE` that sent no body succeeded before and is refused\n`400` now. An integration that erases subscribers has to send the\nconfirmation; one that wanted an unsubscribe was reaching for the wrong\ncall and the refusal names the right one.\n\nPublishes `subscriber.unsubscribed` with `reason: removed`, before the\nrow goes, and only when the subscriber was still `subscribed`. Deleting\nsomebody who had already unsubscribed, bounced or complained publishes\nnothing: they did not stop being mailable, they already had. Reconcile a\nmirror of the list against\n`GET /newsletters/{newsletter}/subscribers` rather than against this\ntopic alone.\n\nAnswers the subscriber as it last stood, so the response is a record of\nwhat was removed rather than an empty body. It is a receipt and not a\nlookup: a `GET` on the same `id` answers `404` from here on. The same\naddress can be added again afterwards, as a new subscription with a new\n`id` and no history.\n",
        "tags": [
          "Subscribers"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "audience:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubscriberEraseRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The subscriber that was erased, as it last stood.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Subscriber"
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/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` threads, which are the ones a reader\nhas 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` ones, so the same collection read\nwith two credentials is two different lengths and\nneither is wrong. Discussions Commune opened under an\narticle are included and carry `is_article_thread` as\n`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"
          }
        }
      },
      "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"
          }
        }
      }
    },
    "/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"
          }
        }
      },
      "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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/newsletters/{newsletter}/automations": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/NewsletterPath"
        }
      ],
      "get": {
        "operationId": "listNewsletterAutomations",
        "summary": "List a newsletter's automations",
        "description": "The newsletter's automations, most recently written first. Needs\n`sending: read`.\n\nArchived ones are left out unless `?status=archived` asks for them.\nDeleting an automation archives it rather than removing it, because the\nruns underneath it are the record of what was emailed to whom, so\nwithout that default the answer to \"what automations do I have\" would be\na history.\n\nEach row is an `AutomationSummary`, which is the automation without its\ngraph. A `send_email` step holds a whole email body, so a page of\nautomations with their graphs inlined would be a newsletter's worth of\nprose; `step_count` is enough to tell an empty draft from a built\nsequence, and `GET /automations/{automation}` has the graph.\n",
        "tags": [
          "Automations"
        ],
        "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 automations in this state. Defaults to every state but\n`archived`. Repeat the parameter to accept several.\n",
            "schema": {
              "$ref": "#/components/schemas/AutomationStatus"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of the newsletter's automations, most recently written first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "This page of the newsletter's automations, most\nrecently written first, each without its graph. Read\none automation to get the steps, the conditions and\nthe emails it sends.\n",
                          "items": {
                            "$ref": "#/components/schemas/AutomationSummary"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "oneLiveOneDraft": {
                    "summary": "A live welcome sequence and a draft nobody has turned on.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "automation_summary",
                          "id": "9c1e4b7a-2d35-4f61-8a90-1b2c3d4e5f60",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "name": "Welcome sequence",
                          "status": "active",
                          "trigger": "subscriber.created",
                          "trigger_filter": null,
                          "reentry": false,
                          "step_count": 3,
                          "created_at": "2026-10-01T09:00:00Z",
                          "updated_at": "2026-10-02T11:15:00Z",
                          "activated_at": "2026-10-02T11:16:00Z"
                        },
                        {
                          "object": "automation_summary",
                          "id": "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9",
                          "newsletter": {
                            "object": "newsletter",
                            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
                          },
                          "name": "Win-back",
                          "status": "draft",
                          "trigger": "subscriber.unsubscribed",
                          "trigger_filter": null,
                          "reentry": true,
                          "step_count": 0,
                          "created_at": "2026-10-05T14:30:00Z",
                          "updated_at": "2026-10-05T14:30:00Z",
                          "activated_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"
          }
        }
      },
      "post": {
        "operationId": "createAutomation",
        "summary": "Create an automation",
        "description": "Writes an automation: what starts it, and what it does. Needs\n`sending: write`, and is a write.\n\n**It is created `draft`, and nothing it writes can email anybody.** The\nengine only ever starts a run of an `active` automation, and the only\nway to that status is `POST /automations/{automation}/activate`, which\nruns every gate again and is the one operation in this contract charged\nto a daily cap of its own.\n\n**The graph arrives whole.** `steps` is every step the automation will\nhave, in the order they run, and `next` defaults to the following entry\nso a linear sequence needs no wiring. There is no operation that adds\none step: a step is only meaningful against the edges pointing at it.\n\n**The emails arrive inline**, as `body_markdown`, converted by the same\nstrict parser `POST /newsletters/{newsletter}/articles` uses. An\nautomation email is not listed, not addressable and not reusable between\nsteps, so there is no id to send instead.\n\n**Every gate runs before anything is stored.** The graph (`422\nunprocessable`): every edge points at a step that exists, every path\nends, and no cycles. Each condition, against the closed keyword subset\nand against the paths its trigger can actually produce. Each email,\nagainst the unsubscribe-and-address footer the law requires and against\na probe of every image in it. A body that would ship a compliance\nviolation never reaches the table, which matters here more than it does\nfor an article: an automation email is written once and then sent\nunattended for months.\n\nPublishes no event.\n",
        "tags": [
          "Automations"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AutomationCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The automation, as a draft, with the graph it was given.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Automation"
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/automation-triggers": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        }
      ],
      "get": {
        "operationId": "listAutomationTriggers",
        "summary": "List the events an automation can trigger on",
        "description": "Every event an automation can be built on, and what a condition may ask\nabout under each. Any credential that reaches a newsletter can read it:\nit describes Commune's event vocabulary rather than anybody's data.\n\nEvery topic Commune publishes is a trigger, including the ones that are\nnot about a subscriber. `carries_subscriber` says which, and it carries\nthree facts at once: a trigger without a subscriber cannot send an\nemail, cannot move a tag, and cannot be asked anything about a person.\n\n**Read this before writing a condition.** `context` lists the property\nnames a condition may use, per branch. That matters because of one\nasymmetry in JSON Schema: an absent property says nothing, so a\ncondition about a property no payload carries **matches every run** and\nthe branch silently always takes the same arm. Saving such a condition\nis refused, naming the path, and this is the list that refusal is\ncomputed from.\n",
        "tags": [
          "Automations"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The triggers, by topic.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "Every topic that can start an automation, in\nalphabetical order, each with the property names a\ncondition may use under it.\n",
                          "items": {
                            "$ref": "#/components/schemas/AutomationTrigger"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "subscriberCreated": {
                    "summary": "A trigger that is about a subscriber, and one that is not.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "automation_trigger",
                          "topic": "article.published",
                          "carries_subscriber": false,
                          "context": {
                            "trigger": "article.published",
                            "event": [
                              "article_id",
                              "audience_scoped",
                              "published_at",
                              "slug",
                              "source",
                              "title",
                              "url"
                            ],
                            "subscriber": [],
                            "steps": []
                          }
                        },
                        {
                          "object": "automation_trigger",
                          "topic": "subscriber.created",
                          "carries_subscriber": true,
                          "context": {
                            "trigger": "subscriber.created",
                            "event": [
                              "acquisition_source",
                              "created_at",
                              "email",
                              "resubscribed",
                              "status",
                              "subscriber_id",
                              "user_id"
                            ],
                            "subscriber": [
                              "acquired_via_commune",
                              "acquisition_source",
                              "created_at",
                              "email",
                              "id",
                              "status",
                              "subscribed_via",
                              "tags"
                            ],
                            "steps": []
                          }
                        }
                      ],
                      "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"
          }
        }
      }
    },
    "/automations/{automation}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/AutomationPath"
        }
      ],
      "get": {
        "operationId": "getAutomation",
        "summary": "Retrieve an automation",
        "description": "One automation with its whole graph: every step, every condition, and\nthe Markdown of every email it sends. Needs `sending: read`.\n\nThe `body_markdown` that comes back is the rendition you sent, with the\npersonalization placeholders as you wrote them, so reading an\nautomation, changing one step and sending the graph back preserves\neverything else.\n",
        "tags": [
          "Automations"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The automation, with its graph.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Automation"
                }
              }
            }
          },
          "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"
          }
        }
      },
      "patch": {
        "operationId": "updateAutomation",
        "summary": "Update an automation",
        "description": "Changes an automation, including the graph. Needs `sending: write`, and\nis a write.\n\n**`steps` replaces the whole graph.** Leaving it out leaves the graph\nalone, which is what a rename is. Sending it replaces it entirely, and\nevery gate the create runs runs again.\n\n**An edit applies to the runs already in flight.** A run sits on a step\n`id`, so a subscriber part way through the sequence picks up the new\nsteps from wherever they are. That is the point of editing a live\nautomation rather than replacing it.\n\n**It is also why removing a step can answer `409`.** If runs are waiting\non a step this edit removes, the request is refused with how many and on\nwhich steps, because removing it would leave those subscribers part way\nthrough with nothing to advance them and nothing reporting it. Send\n`on_removed_step` to say what should happen: `\"exit\"` ends those runs,\nor a step `id` from the graph you are sending moves them there.\n\nPublishes no event.\n",
        "tags": [
          "Automations"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AutomationUpdateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The automation, as it now stands.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Automation"
                }
              }
            }
          },
          "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": "deleteAutomation",
        "summary": "Archive an automation",
        "description": "Takes an automation out of service. Needs `sending: write`, and is a\nwrite.\n\n**It archives rather than erases**, and the distinction is what you\nkeep. The runs underneath an automation are the record of what was\nemailed to whom. Erasing them would mean tidying up a draft you had\nabandoned also destroyed the sending history of the sequence that ran\nlast month, with nothing to say so. So `status` becomes `archived`,\nwhich is not `active`, so it sends nothing, and the automation and its\nruns stay readable.\n\n**The runs it was in the middle of are ended**, with an `exit_reason`\nsaying so. That is the difference from pausing: a pause is coming back\nand holds every run on its step, and an archive is not, so a run holding\nits place would be a subscriber stuck part way through forever.\n\nArchiving an archived automation succeeds and changes nothing. There is\nno way back: write a new automation instead.\n\nPublishes no event.\n",
        "tags": [
          "Automations"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The automation, archived.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Automation"
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/automations/{automation}/activate": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/AutomationPath"
        }
      ],
      "post": {
        "operationId": "activateAutomation",
        "x-commune-destructive": "Turns the automation on. From then on Commune emails subscribers on its own, with no further request, until somebody turns it off. Mail that has left cannot be recalled, so a client should confirm with a person before calling this.",
        "summary": "Turn an automation on",
        "description": "Makes an automation live. Needs `sending: write`, and is a write.\n\n**This is the operation that starts sending.** Everything else about an\nautomation describes a sequence; this installs it. From the moment it\nreturns, Commune mails whoever the trigger names, as often as it fires,\nwith no further request from anybody, until a person turns it off.\n\n**Every gate runs again, on the stored automation.** They ran when the\ngraph was saved, and the repetition is deliberate: an automation can be\nsaved by one tool and turned on by another weeks later, and in between\nan image can rot, a tag a step applies can be deleted, and a sending\ndomain can fall out of verification. Each is a `422` naming what is\nwrong:\n\n  * **the graph**, if an edge points at nothing, a path does not end, or\n    there is a cycle;\n  * **a condition**, if it uses a keyword outside the subset or names a\n    path its trigger cannot produce;\n  * **`missing_footer`**, if an email has lost its unsubscribe link or\n    its mailing address;\n  * **`broken_images`**, if an image in an email definitively will not\n    load. There is no way to override this one here, unlike on an\n    article send: an article's images are checked minutes before it goes\n    out, and an automation's are checked once and then sent for months;\n  * **the newsletter**, if Commune does not send its email, or it has no\n    verified sending address. These apply only when the graph actually\n    sends: an automation that only moves tags works on any newsletter.\n\n**It is charged to a daily cap of its own**, counted per newsletter and\nreported as a `429` that is not one of the `RateLimit-Policy` budgets. An\nactivation is not a send: it installs a standing instruction. The number\nis also how many copies of one email a single subscriber could be made\nto receive from one triggering event, because each automation claims its\ndeliveries separately. An activation that was refused costs nothing, so\nfixing a graph and trying again is free until it succeeds.\n\nTurning on an automation that is already `active` succeeds and changes\nnothing. An `archived` one is refused: archiving ended its runs.\n\nPublishes no event.\n",
        "tags": [
          "Automations"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The automation, live, with `activated_at` stamped.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Automation"
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/automations/{automation}/pause": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/AutomationPath"
        }
      ],
      "post": {
        "operationId": "pauseAutomation",
        "summary": "Turn an automation off",
        "description": "Stops an automation sending. Needs `sending: write`, and is a write.\n\n**It holds every run where it is.** A subscriber three days into a\nsequence stays on that step and resumes from it when the automation is\nturned on again. There is no window in which a paused automation still\nsends, and no drain to wait out.\n\nThat is the difference from archiving, which ends those runs because it\nis not coming back.\n\nPausing a paused automation succeeds and changes nothing. A `draft` or\nan `archived` one is refused: neither is sending, so there is nothing to\npause.\n\nPublishes no event.\n",
        "tags": [
          "Automations"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The automation, paused, with its runs held where they were.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Automation"
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/automations/{automation}/runs": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/AutomationPath"
        }
      ],
      "get": {
        "operationId": "listAutomationRuns",
        "summary": "List an automation's runs",
        "description": "One row per subscriber who entered this automation, most recent first.\nNeeds `sending: read`.\n\nThis is how you answer \"did this sequence reach that person, and where\nare they in it\". `status` and `current_step` say where they are,\n`exit_reason` says why they stopped if they did, and `steps` is what\neach email did: sent, delivered, opened, clicked, bounced, complained,\neach with its own time.\n\nIt carries no email address and no copy of the event that started the\nrun. Both are deliberate: a run is read under `sending`, and an address\nbelongs to `audience`. `subscriber` is a `Ref` that cannot be expanded;\n`GET /subscribers/{subscriber}` resolves it.\n",
        "tags": [
          "Automations"
        ],
        "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 runs in this state. Repeat the parameter to accept\nseveral.\n",
            "schema": {
              "$ref": "#/components/schemas/AutomationRunStatus"
            }
          },
          {
            "name": "subscriber",
            "in": "query",
            "required": false,
            "description": "Return only runs about this subscriber, by subscriber `id`. The\nanswer is one row unless the automation allows re-entry.\n",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of this automation's runs, most recent first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "description": "This page of the automation's runs, most recently\nstarted first, each carrying what its emails did for\nthat subscriber and none of them carrying an address.\n",
                          "items": {
                            "$ref": "#/components/schemas/AutomationRun"
                          }
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "oneWaitingOneExited": {
                    "summary": "A subscriber part way through, and one who unsubscribed during a wait.",
                    "value": {
                      "object": "list",
                      "data": [
                        {
                          "object": "automation_run",
                          "id": "55667788-99aa-4bbc-8dde-ff0011223344",
                          "automation": {
                            "object": "automation",
                            "id": "9c1e4b7a-2d35-4f61-8a90-1b2c3d4e5f60"
                          },
                          "subscriber": {
                            "object": "subscriber",
                            "id": "33445566-7788-4990-a1b2-c3d4e5f60718"
                          },
                          "status": "waiting",
                          "current_step": "settle",
                          "exit_reason": null,
                          "attempts": 2,
                          "steps": [
                            {
                              "step": "welcome",
                              "status": "opened",
                              "failure_reason": null,
                              "sent_at": "2026-10-06T08:00:01Z",
                              "delivered_at": "2026-10-06T08:00:09Z",
                              "opened_at": "2026-10-06T09:14:22Z",
                              "clicked_at": null,
                              "bounced_at": null,
                              "complained_at": null
                            }
                          ],
                          "next_run_at": "2026-10-09T08:00:01Z",
                          "created_at": "2026-10-06T08:00:00Z",
                          "completed_at": null
                        },
                        {
                          "object": "automation_run",
                          "id": "66778899-aabb-4ccd-8eef-001122334455",
                          "automation": {
                            "object": "automation",
                            "id": "9c1e4b7a-2d35-4f61-8a90-1b2c3d4e5f60"
                          },
                          "subscriber": {
                            "object": "subscriber",
                            "id": "44556677-8899-4aab-8cdd-eeff00112233"
                          },
                          "status": "exited",
                          "current_step": "settle",
                          "exit_reason": "subscriber_unsubscribed",
                          "attempts": 3,
                          "steps": [
                            {
                              "step": "welcome",
                              "status": "delivered",
                              "failure_reason": null,
                              "sent_at": "2026-10-03T08:00:01Z",
                              "delivered_at": "2026-10-03T08:00:07Z",
                              "opened_at": null,
                              "clicked_at": null,
                              "bounced_at": null,
                              "complained_at": null
                            }
                          ],
                          "next_run_at": null,
                          "created_at": "2026-10-03T08:00:00Z",
                          "completed_at": "2026-10-06T08:00:02Z"
                        }
                      ],
                      "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"
          }
        }
      }
    },
    "/automations/{automation}/steps/{step}/send-test": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/AutomationPath"
        },
        {
          "$ref": "#/components/parameters/AutomationStepPath"
        }
      ],
      "post": {
        "operationId": "sendAutomationStepTest",
        "summary": "Send a test copy of one step's email",
        "description": "Sends a copy of one `send_email` step's email to addresses you name, so\nsomebody can look at it. Needs `sending: write`, and is a write.\n\nIt touches nothing else: no run, no delivery record, no status change,\nnobody on the list. Run it twice and the only thing that happens twice\nis somebody reading the email.\n\n**It matters more than an article's test send.** An article is written,\nlooked at and sent within an afternoon. An automation email is written\nonce and then sent, unattended, for months, to people who arrive one at\na time, so there is no moment at which anybody watches it go out.\nWithout this the first person to see it rendered is a subscriber.\n\nIt renders through the same renderer and the same personalization pass\nthe real send uses, so what lands is what a subscriber would get, with\nthe placeholders filled in from example values and the unsubscribe link\npointing at a page that explains itself rather than at a live token.\n\n**Three gates, not five.** There has to be a step, an email on it, and a\nverified address to send from. The footer scan and the image probe do\nnot apply: looking at the email is how you notice the footer has gone,\nand both already ran when the graph was saved and run again when the\nautomation is turned on.\n\nLeft out, `to` sends the copy to the address on the account this\ncredential belongs to. That address is not echoed in the answer, which\nsays `sent_to_owner` instead: it is revealed only on `GET /me`, under\n`account: read`.\n\nCharged to the same daily cap an article's test send is, counted per\ncredential, and reported as a `429` that is not one of the\n`RateLimit-Policy` budgets. These two are the only operations here that\nmail somebody who never subscribed.\n\nPublishes no event.\n",
        "tags": [
          "Automations"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AutomationStepTestRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "What became of the copy.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AutomationStepTest"
                },
                "examples": {
                  "toTwoColleagues": {
                    "summary": "A copy to two named addresses.",
                    "value": {
                      "object": "automation_step_test",
                      "automation": {
                        "object": "automation",
                        "id": "9c1e4b7a-2d35-4f61-8a90-1b2c3d4e5f60"
                      },
                      "step": "welcome",
                      "subject": "Welcome, Alex",
                      "recipients": [
                        "me@example.com",
                        "editor@example.com"
                      ],
                      "sent_to_owner": false,
                      "sent": 2,
                      "failed": 0
                    }
                  }
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/runs/{run}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommuneVersion"
        },
        {
          "$ref": "#/components/parameters/AutomationRunPath"
        }
      ],
      "get": {
        "operationId": "getAutomationRun",
        "summary": "Retrieve an automation run",
        "description": "One subscriber's journey through one automation. Needs `sending: read`.\n\nA run `id` already names the automation it belongs to, so the path does\nnot repeat it. Everything the collection says about what a run carries,\nand what it deliberately does not, applies here unchanged: no email\naddress, and no copy of the event that started it.\n",
        "tags": [
          "Automations"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "sending:read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          },
          {
            "$ref": "#/components/parameters/Fields"
          }
        ],
        "responses": {
          "200": {
            "description": "The run, with what each of its emails did.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AutomationRun"
                }
              }
            }
          },
          "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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "/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"
          }
        }
      }
    },
    "automation.run.completed": {
      "post": {
        "operationId": "onAutomationRunCompleted",
        "summary": "Automation run completed",
        "description": "A run of one of the newsletter's automations reached the end of its\ngraph. Every step it was going to take has been taken.\n\nA run is one subscriber's passage through one automation, so this fires\nonce per person per entry rather than once per automation. Read\n`GET /runs/{run}` for the step history; this message carries the\noutcome, not the walk.\n\n**`actor` names the run, not a credential.** Every `automation.run.*`\nmessage carries `actor: {\"type\": \"system\", \"id\": \"<run_id>\"}`, which is\nthe same attribution Commune puts on the events a run's own actions\ncause, such as the `subscriber.tagged` of an `add_tag` step. That is\nwhat the loop guard reads: a run started by this message inherits this\nrun's chain plus this run's automation, so an automation triggered by\nits own runs starts one further run and is then refused.\n`data.chain_depth` is how many automations' actions already led here,\nout of a limit of five.\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/AutomationRunCompletedEvent"
              },
              "examples": {
                "welcomeFinished": {
                  "summary": "A new subscriber reached the end of a three-email welcome sequence.",
                  "value": {
                    "id": "018f2a93-7070-7000-8000-000000000071",
                    "type": "automation.run.completed",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-31T09:00:04Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": {
                      "type": "system",
                      "id": "2b7c4d19-6e03-4f88-9a21-5c6d7e8f9a0b",
                      "label": "automation: Welcome sequence"
                    },
                    "idempotency_key": null,
                    "data": {
                      "run_id": "2b7c4d19-6e03-4f88-9a21-5c6d7e8f9a0b",
                      "automation_id": "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f",
                      "automation_name": "Welcome sequence",
                      "trigger": "subscriber.created",
                      "subscriber_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
                      "event_id": "018f2a93-5050-7000-8000-000000000051",
                      "chain_depth": 0,
                      "started_at": "2026-08-28T09:00:01Z",
                      "completed_at": "2026-08-31T09:00:04Z"
                    }
                  }
                }
              }
            }
          }
        },
        "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"
          }
        }
      }
    },
    "automation.run.exited": {
      "post": {
        "operationId": "onAutomationRunExited",
        "summary": "Automation run ended early",
        "description": "A run stopped before the end of its graph. `data.status` says which of\nthe two ways it stopped and `data.reason` says why.\n\n`exited` is the ordinary case and is not an error: the subscriber\nunsubscribed, bounced or complained part way through a sequence, a\ncondition's branch ran into a `stop` step, the creator archived the\nautomation, or a live edit removed the step the run was sitting on.\n`failed` is a run Commune could not advance and has given up retrying,\nwhich is the one that wants a person.\n\nOne topic for both, rather than one per status, for the reason\n`article.liked` carries both directions: a consumer watching whether\nits creator's sequences finish needs every early ending, and splitting\nthem would let it subscribe to half and believe it had them all. Route\non `data.status` if the distinction matters.\n\n`data.reason` is a short Commune-chosen code for an `exited` run\n(`subscriber_unsubscribed`, `step_removed`, `automation_archived`,\n`sender_not_verified`, and others; the set grows as the engine learns\nnew ways to stop) and the last error text for a `failed` one. New codes\nare not a breaking change, so treat an unrecognised one as \"ended, for\na reason I do not handle\" rather than as a parse failure.\n\nNo message is published for a run the loop guard refused, because no\nrun was created and there would be no `run_id` to name. See\n`automation.run.completed` on how the guard reads `actor`.\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/AutomationRunExitedEvent"
              },
              "examples": {
                "unsubscribedMidSequence": {
                  "summary": "The reader unsubscribed between the first and second email, so the rest of the sequence never reached them.",
                  "value": {
                    "id": "018f2a93-8080-7000-8000-000000000081",
                    "type": "automation.run.exited",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-29T14:22:10Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": {
                      "type": "system",
                      "id": "2b7c4d19-6e03-4f88-9a21-5c6d7e8f9a0b",
                      "label": "automation: Welcome sequence"
                    },
                    "idempotency_key": null,
                    "data": {
                      "run_id": "2b7c4d19-6e03-4f88-9a21-5c6d7e8f9a0b",
                      "automation_id": "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f",
                      "automation_name": "Welcome sequence",
                      "trigger": "subscriber.created",
                      "subscriber_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
                      "event_id": "018f2a93-5050-7000-8000-000000000051",
                      "chain_depth": 0,
                      "started_at": "2026-08-28T09:00:01Z",
                      "completed_at": "2026-08-29T14:22:10Z",
                      "status": "exited",
                      "reason": "subscriber_unsubscribed"
                    }
                  }
                },
                "gaveUp": {
                  "summary": "Commune could not advance the run and stopped retrying. This is the one that wants a person.",
                  "value": {
                    "id": "018f2a93-9090-7000-8000-000000000091",
                    "type": "automation.run.exited",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-29T16:40:00Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": {
                      "type": "system",
                      "id": "3c8d5e2a-7f14-4099-8b32-6d7e8f9a0b1c",
                      "label": "automation: Welcome sequence"
                    },
                    "idempotency_key": null,
                    "data": {
                      "run_id": "3c8d5e2a-7f14-4099-8b32-6d7e8f9a0b1c",
                      "automation_id": "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f",
                      "automation_name": "Welcome sequence",
                      "trigger": "subscriber.created",
                      "subscriber_id": "6f7a8b9c-0d1e-4f2a-8b3c-4d5e6f7a8b9c",
                      "event_id": "018f2a93-6060-7000-8000-000000000061",
                      "chain_depth": 0,
                      "started_at": "2026-08-29T09:00:01Z",
                      "completed_at": "2026-08-29T16:40:00Z",
                      "status": "failed",
                      "reason": "send_blocked:fair_use_limit"
                    }
                  }
                }
              }
            }
          }
        },
        "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"
          }
        }
      }
    },
    "automation.run.started": {
      "post": {
        "operationId": "onAutomationRunStarted",
        "summary": "Automation run started",
        "description": "An event matched a live automation's trigger and Commune created a run\nfor it. The run is one subscriber's passage through that automation.\n\nIt fires when the run row is written, which is before the first step\nexecutes, so the email a `send_email` step sends has not been sent yet\nwhen this arrives. Pair it with `automation.run.completed` and\n`automation.run.exited` to know how the run ended; exactly one of those\ntwo follows every run that ends.\n\n**Nothing is published for a run that was not created.** An event the\ntrigger filter declined, a subscriber the reentry rule has already sent\nthrough, and a run the loop guard refused all leave no row and so no\nmessage. A creator's second automation that never fires is a question\nfor the dashboard, not a silence this topic reports.\n\n`data.subscriber_id` is null when the run's trigger is not about a\nperson, which is every topic except the `subscriber.*` and `delivery.*`\nones. Such a run can branch on its event and wait, but it has nobody to\nemail or tag.\n\n**`actor` names this run.** See `automation.run.completed` for what the\nloop guard does with 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/AutomationRunStartedEvent"
              },
              "examples": {
                "welcomeEntered": {
                  "summary": "A new subscriber entered the welcome sequence.",
                  "value": {
                    "id": "018f2a93-6060-7000-8000-000000000062",
                    "type": "automation.run.started",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-28T09:00:01Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": {
                      "type": "system",
                      "id": "2b7c4d19-6e03-4f88-9a21-5c6d7e8f9a0b",
                      "label": "automation: Welcome sequence"
                    },
                    "idempotency_key": null,
                    "data": {
                      "run_id": "2b7c4d19-6e03-4f88-9a21-5c6d7e8f9a0b",
                      "automation_id": "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f",
                      "automation_name": "Welcome sequence",
                      "trigger": "subscriber.created",
                      "subscriber_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
                      "event_id": "018f2a93-5050-7000-8000-000000000051",
                      "chain_depth": 0,
                      "started_at": "2026-08-28T09:00:01Z"
                    }
                  }
                },
                "secondHop": {
                  "summary": "A run started by what another automation did. One automation already led here, so a chain five deep is refused rather than started.",
                  "value": {
                    "id": "018f2a93-6061-7000-8000-000000000063",
                    "type": "automation.run.started",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-28T09:04:00Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": {
                      "type": "system",
                      "id": "4d9e6f3b-8a25-41aa-9c43-7e8f9a0b1c2d",
                      "label": "automation: Tag the VIPs"
                    },
                    "idempotency_key": null,
                    "data": {
                      "run_id": "4d9e6f3b-8a25-41aa-9c43-7e8f9a0b1c2d",
                      "automation_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
                      "automation_name": "Tag the VIPs",
                      "trigger": "subscriber.tagged",
                      "subscriber_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
                      "event_id": "018f2a93-5051-7000-8000-000000000052",
                      "chain_depth": 1,
                      "started_at": "2026-08-28T09:04: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\n**Two kinds of email reach this topic.** `data.source` is\n`article_send` for an article the creator sent to the list and\n`automation` for one `send_email` step of an automation run. It\ndecides which pair of ids is filled in; the other pair is null.\nThis is a breaking change from the shape that only ever carried\nan article, described on the body schema.\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",
                      "source": "article_send",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "run_id": null,
                      "step_id": null,
                      "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\n**Two kinds of email reach this topic.** `data.source` is\n`article_send` for an article the creator sent to the list and\n`automation` for one `send_email` step of an automation run. It\ndecides which pair of ids is filled in; the other pair is null.\nThis is a breaking change from the shape that only ever carried\nan article, described on the body schema.\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",
                      "source": "article_send",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "run_id": null,
                      "step_id": null,
                      "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\n**Two kinds of email reach this topic.** `data.source` is\n`article_send` for an article the creator sent to the list and\n`automation` for one `send_email` step of an automation run. It\ndecides which pair of ids is filled in; the other pair is null.\nThis is a breaking change from the shape that only ever carried\nan article, described on the body schema.\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",
                      "source": "article_send",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "run_id": null,
                      "step_id": null,
                      "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\n**Two kinds of email reach this topic.** `data.source` is\n`article_send` for an article the creator sent to the list and\n`automation` for one `send_email` step of an automation run. It\ndecides which pair of ids is filled in; the other pair is null.\nThis is a breaking change from the shape that only ever carried\nan article, described on the body schema.\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",
                      "source": "article_send",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "run_id": null,
                      "step_id": null,
                      "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\n**Two kinds of email reach this topic.** `data.source` is\n`article_send` for an article the creator sent to the list and\n`automation` for one `send_email` step of an automation run. It\ndecides which pair of ids is filled in; the other pair is null.\nThis is a breaking change from the shape that only ever carried\nan article, described on the body schema.\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",
                      "source": "article_send",
                      "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
                      "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
                      "run_id": null,
                      "step_id": null,
                      "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
                      "email": "reader@example.com",
                      "provider_message_id": "4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4",
                      "status": "opened"
                    }
                  }
                },
                "openedFromAnAutomation": {
                  "summary": "The same topic for an email one step of a welcome sequence sent. `source` is `automation`, so the article ids are null and the run and step name what sent it.",
                  "value": {
                    "id": "018f2a90-5556-7000-8000-000000000006",
                    "type": "delivery.opened",
                    "api_version": "2026-08-26",
                    "occurred_at": "2026-08-28T11:02:40Z",
                    "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
                    "actor": null,
                    "idempotency_key": null,
                    "data": {
                      "delivery_id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
                      "source": "automation",
                      "article_id": null,
                      "send_id": null,
                      "run_id": "2b7c4d19-6e03-4f88-9a21-5c6d7e8f9a0b",
                      "step_id": "welcome",
                      "subscriber_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
                      "email": "reader@example.com",
                      "provider_message_id": "5fa0a3c2-1d44-4d2c-9a0b-88d3f6e2b4c5",
                      "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. Five paths land here:\nsubscribing from inside the Commune app, finishing signup, accepting an\ninvitation, being subscribed to Commune's own newsletter, and Add a\nsubscriber (`POST /newsletters/{newsletter}/subscribers`).\n\n**An import does not land here, and that is deliberate.** Neither a CSV\nupload nor an ESP import nor the daily provider sync fires this topic:\nan import writes many rows at once and must not fan out one message per\nrow, so it publishes a single `import.completed` instead. Nothing that\nkeys off this topic therefore reaches an imported list, which is what\nkeeps a welcome sequence off a list the creator migrated in.\n\n`data.actor` is what tells the API path apart from the four the product\nreaches. It names the credential on a `POST`, and is null when a person\ndid something in Commune itself.\n\n`data.acquisition_source` records which import first brought an address\nand is fixed when the row is created, so it is null for anybody first\nrecorded through Commune. A row that was first imported reaches this\ntopic only by coming back (see below), and carries that provider's slug\nor `csv` when it does.\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`.\nSetting `status` back to `subscribed` through Update a subscriber\n(`PATCH /subscribers/{subscriber}`) fires it the same way, and so does a\n`POST` naming an address that is already on the list but inactive.\nBranch on `resubscribed` before you welcome anybody.\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\n`removed`: the newsletter took them off its list, which is the only\nreason the reader did not cause. `actor` names the credential that did\nit. It covers two different writes and the difference is whether the\nrecord survived: Update a subscriber\n(`PATCH /subscribers/{subscriber}`) sets the status and keeps the row,\nand Erase a subscriber (`DELETE /subscribers/{subscriber}`) erases it\nalong with its tags and its delivery history. After a deletion\n`GET /subscribers/{subscriber}` answers `404`, which is how a consumer\ntells them apart.\n\nOn the other three reasons the subscriber record survives with its\nstatus changed, so bounce and complaint history is kept and a later\nresubscribe 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. Those are the only two values.\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"
          }
        }
      },
      "SitePagePath": {
        "name": "page",
        "in": "path",
        "required": true,
        "description": "The website page's `id`.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "PublishableKeyPath": {
        "name": "publishable_key",
        "in": "path",
        "required": true,
        "description": "The publishable key's `id`, which is not the key itself. The secret is\nnever an identifier: it is shown once and only its digest is kept.\n",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "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"
        }
      },
      "AutomationPath": {
        "name": "automation",
        "in": "path",
        "required": true,
        "description": "The automation's `id`. An automation has no short id.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "AutomationRunPath": {
        "name": "run",
        "in": "path",
        "required": true,
        "description": "The run's `id`. A run identifies one subscriber's journey through one\nautomation, so it already names the automation and the path does not\nrepeat it.\n",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "AutomationStepPath": {
        "name": "step",
        "in": "path",
        "required": true,
        "description": "The step's `id` within its automation, which you chose when you wrote the\ngraph. Not a UUID.\n",
        "schema": {
          "type": "string",
          "maxLength": 100,
          "pattern": "^[a-z0-9][a-z0-9_-]*$"
        }
      },
      "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\nThree 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\n**`sendAutomationStepTest`.** The same thing for one of an automation's\nemails, answering for the same two reasons and leaving the automation\nuntouched either way. Only the test copy can answer this: an automation\nthat is live sends from a worker rather than from a request, so there is\nno request open to tell.\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 hostname, attached to one of their newsletters, and\nwhich Commune website origin answers for it. The certificate provider's\ninternal identifier is not exposed.\n\n**`mode` was added to this shape.** A client validating responses\nagainst the previous contract will reject a payload carrying it, because\nthis schema admits no property it does not declare. There is no version\nto pin to that leaves it out: it is on every `Domain` from now on. The\nreason it could not wait is the field itself. A hostname now serves\neither the Commune newsletter page or the creator's own pages, those two\norigins have different security properties, and a response that did not\nsay which was describing one of them as the other.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "domain",
          "mode",
          "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"
            ]
          },
          "mode": {
            "$ref": "#/components/schemas/DomainMode"
          },
          "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."
          }
        }
      },
      "DomainMode": {
        "type": "string",
        "title": "DomainMode",
        "description": "What a hostname serves.\n\n`app` is the Commune newsletter page, with sessions: readers sign in,\nchat and highlight there, and it is the mode every hostname starts on.\n`site` is the creator's own pages, served from an origin that never\ncarries a Commune session.\n\nOne host can carry a session or run creator JavaScript, never both,\nbecause the origin is the only security boundary a browser has: code\nrunning on an origin that holds a session can call Commune with the\nvisitor's cookie. So a hostname is one or the other, and a newsletter\nholds at most one of each.\n",
        "enum": [
          "app",
          "site"
        ]
      },
      "DomainUpdateRequest": {
        "type": "object",
        "title": "DomainUpdateRequest",
        "description": "What may be changed about a website domain.",
        "additionalProperties": false,
        "required": [
          "mode"
        ],
        "properties": {
          "mode": {
            "$ref": "#/components/schemas/DomainMode"
          }
        }
      },
      "SitePageKind": {
        "type": "string",
        "title": "SitePageKind",
        "description": "What a website page is for, which also decides what its `path` means.\n\n`page` is a fixed path and anything the creator wants. `newsletter` is\nthe article list, at most one per newsletter, and its path is where\nCommune points the newsletter's name and avatar. `article` is a URL\npattern containing `:article` that renders every article, so its path is\na pattern rather than an address.\n",
        "enum": [
          "page",
          "newsletter",
          "article"
        ]
      },
      "SitePageStatus": {
        "type": "string",
        "title": "SitePageStatus",
        "description": "Whether a page has a public address. A `draft` is reachable only through\nits signed `preview_url`; an edit to a `published` page is live on\ncommit, because a creator website has no deploy step.\n",
        "enum": [
          "draft",
          "published"
        ]
      },
      "SitePageSummary": {
        "type": "object",
        "title": "SitePageSummary",
        "description": "One page of a creator website, without its document.\n\nWhat a collection returns. A page holds up to 2MB of HTML and a website\nholds many, so the listing carries the address, the state and the weight\nand the single read carries the bytes.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "kind",
          "path",
          "status",
          "blocked",
          "meta",
          "byte_size",
          "url",
          "preview_url",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "site_page_summary",
            "description": "Always `site_page_summary`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "newsletter": {
            "description": "The newsletter whose website this page belongs to. A `Ref` unless\n`newsletter` is named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "kind": {
            "$ref": "#/components/schemas/SitePageKind"
          },
          "path": {
            "type": "string",
            "description": "Where the page answers: an absolute path for a `page` or a\n`newsletter` page, and a URL pattern for an `article` one.\n",
            "examples": [
              "/about",
              "/posts/:article"
            ]
          },
          "status": {
            "$ref": "#/components/schemas/SitePageStatus"
          },
          "blocked": {
            "type": "boolean",
            "description": "Whether Commune has taken this page down. A blocked page answers a\nrefusal to visitors, stays out of the sitemap, keeps its address\nreserved so nothing else can claim it, and cannot be changed or\ndeleted through this API.\n"
          },
          "meta": {
            "type": "object",
            "description": "Metadata Commune injects into the page's `<head>` when it serves\nit: a title, a description, a social image. Free-form, because the\nset of tags worth injecting grows and a fixed list would make each\naddition a new contract.\n",
            "additionalProperties": true
          },
          "byte_size": {
            "type": "integer",
            "minimum": 0,
            "description": "How large the document is, in bytes rather than characters, against\na limit of 2097152. Bytes because the limit is in bytes: a document\nof emoji weighs three to four times its character count.\n"
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Where the page is served, or null. Null for four different reasons\nand each one is a URL that would not work: a draft has no public\naddress, a page Commune has taken down answers a refusal, an\n`article` page is a pattern rather than one address, and a\nnewsletter whose website hostname is not live yet has nowhere to\nserve any of them from.\n"
          },
          "preview_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "A signed link that opens this one draft, or null.\n\nIt names this page and this newsletter and expires after a week,\nand it opens nothing else: a link pasted to a collaborator reaches\nthat page and no other draft on the site. Null for a published page,\nwhich has a real address, and null when Commune holds no signing key\non this deployment, which is the only safe direction for a gate in\nfront of unpublished work.\n\nAn `article` page previews at the website's root, because its path\nis a pattern and there is no single URL to put the token on.\n"
          },
          "published_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the page first went live. It records the first publication\nrather than the current state, so unpublishing deliberately leaves\nit: `status` is what says whether the page is live now.\n"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the page was written."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When it last changed."
          }
        }
      },
      "SitePage": {
        "type": "object",
        "title": "SitePage",
        "description": "One page of a creator website, with its document.\n\n`html` is exactly what was written, byte for byte. Commune stores it\nunchanged and injects search metadata as it serves the page rather than\nas it keeps it, so reading a page back and writing it again is a round\ntrip that changes nothing.\n\nA separate schema from `SitePageSummary` rather than a composition of\nit, and `object` is `site_page` rather than `site_page_summary`, for the\nreason `ArticleSummary` gives about its own pair: a client routing on\n`object` never has to guess whether a missing property was withheld or\nunset. Everything here except `html` is the summary's, field for field.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "kind",
          "path",
          "status",
          "blocked",
          "meta",
          "byte_size",
          "url",
          "preview_url",
          "created_at",
          "updated_at",
          "html"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "site_page",
            "description": "Always `site_page`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "newsletter": {
            "description": "The newsletter whose website this page belongs to. A `Ref` unless\n`newsletter` is named in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "kind": {
            "$ref": "#/components/schemas/SitePageKind"
          },
          "path": {
            "type": "string",
            "description": "Where the page answers: an absolute path for a `page` or a\n`newsletter` page, and a URL pattern for an `article` one.\n",
            "examples": [
              "/about",
              "/posts/:article"
            ]
          },
          "status": {
            "$ref": "#/components/schemas/SitePageStatus"
          },
          "blocked": {
            "type": "boolean",
            "description": "Whether Commune has taken this page down. A blocked page answers a\nrefusal to visitors, stays out of the sitemap, keeps its address\nreserved so nothing else can claim it, and cannot be changed or\ndeleted through this API.\n"
          },
          "meta": {
            "type": "object",
            "description": "Metadata Commune injects into the page's `<head>` when it serves it:\na title, a description, a social image.\n",
            "additionalProperties": true
          },
          "byte_size": {
            "type": "integer",
            "minimum": 0,
            "description": "How large the document is, in bytes rather than characters, against\na limit of 2097152.\n"
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Where the page is served, or null. Null for a draft, for a page\nCommune has taken down, for an `article` page, which is a pattern\nrather than one address, and for a newsletter whose website hostname\nis not live yet.\n"
          },
          "preview_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "A signed link that opens this one draft, or null. It names this page\nand this newsletter, expires after a week, and opens nothing else.\nNull for a published page and when Commune holds no signing key on\nthis deployment.\n"
          },
          "published_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the page first went live. It records the first publication\nrather than the current state, so unpublishing deliberately leaves\nit.\n"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the page was written."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When it last changed."
          },
          "html": {
            "type": "string",
            "description": "The whole document, with its CSS and JavaScript inline or linked. At\nmost 2097152 bytes.\n"
          }
        }
      },
      "SitePageCreateRequest": {
        "type": "object",
        "title": "SitePageCreateRequest",
        "description": "A page to write.",
        "additionalProperties": false,
        "required": [
          "kind",
          "path",
          "html"
        ],
        "properties": {
          "kind": {
            "$ref": "#/components/schemas/SitePageKind"
          },
          "path": {
            "type": "string",
            "maxLength": 512,
            "description": "An absolute path for a `page` or a `newsletter` page, and a URL\npattern for an `article` one. Normalised on the way in: a trailing\nslash is dropped, and a query string or a fragment is refused rather\nthan trimmed, because silently turning `/about?v=2` into `/about`\nwould leave you with a page at an address you did not ask for.\n",
            "examples": [
              "/about",
              "/blog/:year/:month/:article"
            ]
          },
          "html": {
            "type": "string",
            "description": "The whole document. At most 2097152 bytes; link media, fonts and\nshared stylesheets or scripts as assets rather than inlining them.\n"
          },
          "meta": {
            "type": "object",
            "description": "Metadata to inject into the page's `<head>`. Omit for none.\n",
            "additionalProperties": true
          },
          "status": {
            "$ref": "#/components/schemas/SitePageStatus"
          }
        }
      },
      "SitePageUpdateRequest": {
        "type": "object",
        "title": "SitePageUpdateRequest",
        "description": "What may be changed about a page. Send at least one property; an omitted\none is left alone.\n\n`kind` and `status` are deliberately absent. The kind decides whether\n`path` is a path or a pattern, and the state has two operations of its\nown so that editing your text cannot put a page live.\n",
        "additionalProperties": false,
        "minProperties": 1,
        "properties": {
          "path": {
            "type": "string",
            "maxLength": 512,
            "description": "The page's new address, under the same rules as on create."
          },
          "html": {
            "type": "string",
            "description": "The whole document, replacing what was there. There is no partial\nedit of a page: a page is one document, and nothing in it is\nstructured enough for Commune to patch.\n"
          },
          "meta": {
            "type": "object",
            "description": "The metadata to inject, replacing what was there.",
            "additionalProperties": true
          }
        }
      },
      "SiteAsset": {
        "type": "object",
        "title": "SiteAsset",
        "description": "A file a creator website links to, at a URL that does not change.\n\nThe bytes never pass through Commune: they go straight from the caller\nto storage through the one-time URL on `upload`, which is why `url`\nanswers `404` until they arrive and why `byte_size` is null until then.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "filename",
          "content_type",
          "url",
          "byte_size",
          "uploaded_at",
          "created_at",
          "upload"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "site_asset",
            "description": "Always `site_asset`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "newsletter": {
            "description": "The newsletter whose website this file belongs to. Always a `Ref`\nhere: this shape is only ever returned by the operation that stores\na file, which takes no `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "filename": {
            "type": "string",
            "description": "What you called the file. The stored path carries a random component\nso one upload can never overwrite another, which also makes the\nstored path useless to show, so this is what is shown instead.\n"
          },
          "content_type": {
            "type": "string",
            "description": "The type the upload was minted for. Storage enforces it on the\nupload itself, so a file of a different type is refused there rather\nthan stored under the wrong type.\n",
            "examples": [
              "image/png",
              "font/woff2"
            ]
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Where the file is served, permanently. Put this in your HTML. It\nanswers `404` until the bytes have been uploaded.\n"
          },
          "byte_size": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "description": "How large the stored file is, or null until it arrives. Commune does\nnot see the bytes, so this is what storage reported rather than what\nwas promised.\n"
          },
          "uploaded_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When storage accepted the file, or null while it is still expected.\n"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the upload was minted."
          },
          "upload": {
            "description": "Where to send the file.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/SiteAssetUpload"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "SiteAssetUpload": {
        "type": "object",
        "title": "SiteAssetUpload",
        "description": "A one-time upload. Send the file's bytes as the request body with these\nheaders before it expires; for example\n`curl -X PUT -H \"Content-Type: font/woff2\" --data-binary @inter.woff2 \"<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."
          }
        }
      },
      "SiteAssetCreateRequest": {
        "type": "object",
        "title": "SiteAssetCreateRequest",
        "description": "A file to store.",
        "additionalProperties": false,
        "required": [
          "filename",
          "content_type"
        ],
        "properties": {
          "filename": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255,
            "description": "What to call the file. Shown back to you and used to build the\nstored path, which also carries a random component so an upload\ncannot overwrite an earlier one.\n"
          },
          "content_type": {
            "type": "string",
            "description": "The file's type. Images, video, audio, fonts, stylesheets, scripts\nand JSON are accepted; `image/svg+xml` is not, because an SVG\nnavigated to directly runs its own script on the storage origin\nevery creator's assets share. An unaccepted type answers `400` and\nlists the ones that are.\n",
            "examples": [
              "image/png",
              "font/woff2",
              "text/css"
            ]
          }
        }
      },
      "PublishableKey": {
        "type": "object",
        "title": "PublishableKey",
        "description": "A read-only, newsletter-scoped key that belongs in a creator website's\npage source.\n\nIt is `cmn_pk_`-prefixed rather than `cmn_sk_` because it is meant to be\npublic: it ships inside HTML anybody can read, and the prefix is what\nmakes that recognisable to a creator, a reviewer and a secret scanner.\nWhat makes it safe is what it can reach, which is this one newsletter's\nalready-published rows, from this newsletter's own website origins,\ninside a rate limit. Never an email address, never a draft, never a\ntag-scoped article, and never another newsletter.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "name",
          "key_prefix",
          "secret",
          "allowed_origins",
          "origins",
          "rate_limit_per_minute",
          "monthly_request_quota",
          "created_at",
          "last_used_at",
          "revoked_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "publishable_key",
            "description": "Always `publishable_key`."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Stable identifier."
          },
          "newsletter": {
            "description": "The newsletter this key reads. A `Ref` unless `newsletter` is named\nin `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "name": {
            "type": "string",
            "description": "Your label for the key. Not unique: two keys called `Website` is\nyour business rather than Commune's.\n"
          },
          "key_prefix": {
            "type": "string",
            "description": "The non-secret start of the key, for recognising it. Never used to\nlook one up.\n",
            "examples": [
              "cmn_pk_7Qa2Lk9x"
            ]
          },
          "secret": {
            "type": [
              "string",
              "null"
            ],
            "description": "The key itself, present exactly once: in the response to the call\nthat minted it. Null in every other response, including a list,\nbecause Commune keeps only a digest of it and cannot return it\nagain. A key you have lost is a key you rotate.\n"
          },
          "allowed_origins": {
            "type": "array",
            "description": "Extra browser origins this key is accepted from, on top of the\nnewsletter's own. For a development machine or a staging host. Each\nentry is a bare origin: a scheme, a host and an optional port, with\nno path, no trailing slash and no wildcard, because an origin check\nthat accepted a wildcard would be one anybody could pass.\n",
            "maxItems": 20,
            "items": {
              "type": "string"
            },
            "examples": [
              [
                "http://localhost:5173"
              ]
            ]
          },
          "origins": {
            "type": "array",
            "description": "Every origin this key is accepted from, which is what\n`allowed_origins` adds to rather than replaces: the newsletter's\nlive website hostname when it has one, the Commune-provided\nsubdomain, and then the extra origins above. An empty\n`allowed_origins` therefore means \"the real website and nowhere\nelse\" rather than \"nowhere\".\n",
            "items": {
              "type": "string"
            }
          },
          "rate_limit_per_minute": {
            "type": "integer",
            "minimum": 1,
            "description": "How many requests a minute this key may make, counted across every\npage using it. It is a property of the key rather than of the\nnewsletter, so a staging key can be given a small one.\n"
          },
          "monthly_request_quota": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "description": "A monthly ceiling on requests, or null for none."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the key was minted."
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When a request last arrived with it, to the nearest minute. How you\ntell which key your deployed pages are actually calling before you\nturn the other one off. Null for a key nothing has used.\n"
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the key was turned off, or null while it works. Revocation is a\nstamp rather than a deletion: a key that was abused has to stay\nnameable.\n"
          }
        }
      },
      "PublishableKeyCreateRequest": {
        "type": "object",
        "title": "PublishableKeyCreateRequest",
        "description": "A publishable key to mint.",
        "additionalProperties": false,
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Your label for the key."
          },
          "allowed_origins": {
            "type": "array",
            "description": "Extra origins to accept the key from, on top of the newsletter's own\nwebsite origins, which are always accepted. Omit for none.\n",
            "maxItems": 20,
            "items": {
              "type": "string"
            }
          },
          "rate_limit_per_minute": {
            "type": "integer",
            "minimum": 1,
            "maximum": 6000,
            "default": 600,
            "description": "Requests a minute this key may make."
          },
          "monthly_request_quota": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "description": "A monthly ceiling on requests. Omit for none."
          }
        }
      },
      "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"
        ]
      },
      "WritableSubscriberStatus": {
        "type": "string",
        "title": "WritableSubscriberStatus",
        "description": "The two states a caller may put a subscription in: on the list, or off\nit.\n\nA narrower set than `SubscriberStatus` on purpose, and the three that\nare missing are missing for two different reasons. `bounced` and\n`complained` are verdicts a mailbox provider reached, so a caller\nsetting one would be asserting somebody else's answer on their behalf;\nthey arrive on their own, with the matching `delivery.*` event.\n`pending` belongs to a confirmation flow nothing in this API starts, so\nit would be a state nothing could ever move a subscriber out of.\n",
        "enum": [
          "subscribed",
          "unsubscribed"
        ]
      },
      "SubscriberCreateRequest": {
        "type": "object",
        "title": "SubscriberCreateRequest",
        "description": "An address to put on the newsletter's list.\n\nOne property, and the absences are decisions. There is no `status`,\nbecause adding somebody means subscribing them and a subscriber who is\nnot subscribed is not something to create. There is no `tags`, because a\ntag decides what a person can read and granting that is its own call\n(`POST /subscribers/{subscriber}/tags/{tag}`). There is no `user`,\nbecause whether an address belongs to a Commune account is a fact\nCommune resolves rather than a caller's to claim.\n",
        "additionalProperties": false,
        "required": [
          "email"
        ],
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "minLength": 3,
            "maxLength": 320,
            "description": "Where the newsletter should reach this person. Matched\ncase-insensitively against the list and stored lowercased, so two\ncalls differing only in case are one subscriber.\n"
          }
        },
        "examples": [
          {
            "email": "new.reader@example.com"
          }
        ]
      },
      "SubscriberUpdateRequest": {
        "type": "object",
        "title": "SubscriberUpdateRequest",
        "description": "Whether this subscriber is on the list.\n\n`status` is the whole of it. The address is not writable: it is the\nsubscription's identity, and on a subscriber with a Commune account\nbehind them it lives on that account, so writing it here would give one\nperson two addresses. Add the right address and delete the wrong one.\n",
        "additionalProperties": false,
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "$ref": "#/components/schemas/WritableSubscriberStatus"
          }
        },
        "examples": [
          {
            "status": "unsubscribed"
          }
        ]
      },
      "SubscriberEraseRequest": {
        "type": "object",
        "title": "SubscriberEraseRequest",
        "description": "A statement that this call is meant to erase somebody.\n\nIt holds no data about the subscription and nothing here is stored. Its\nonly job is to make erasure something a caller asks for deliberately,\nbecause erasure cannot be undone and the call that performs it sits one\nHTTP method away from the call that merely stops mail\n(`PATCH /subscribers/{subscriber}`).\n\n`confirm` takes one exact string and deliberately not a boolean. A\ncaller filling in a flag it has not read about sends `true`, so a\nboolean would have been a gate whose guessable value is the permissive\none. The accepted value names what the erasure takes with it, so writing\nit is a statement rather than a formality.\n",
        "additionalProperties": false,
        "required": [
          "confirm"
        ],
        "properties": {
          "confirm": {
            "type": "string",
            "const": "erase_subscriber_and_history",
            "description": "Always `erase_subscriber_and_history`, written out in full.\n\nAnything else, including `true` and including an absent body, is\nrefused with `400`, `param` set to `confirm` and `allowed_values`\nholding the one value this accepts. Nothing is erased by a refused\nrequest.\n"
          }
        },
        "examples": [
          {
            "confirm": "erase_subscriber_and_history"
          }
        ]
      },
      "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 subscription was\nmade on Commune: the person pressed subscribe, finished signup or\naccepted an invitation, or the newsletter's own integration added\nthem with `POST /newsletters/{newsletter}/subscribers`. `imported`\nwhen they arrived in an import of the newsletter's provider list or\na 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. Set and changed by the newsletter's team.\n\nA third value, `paid`, was in this enum until 2026-10-10. It was never\nproduced by anything and no thread ever carried it, so it was removed\nrather than left as a state consumers had to handle. See Removals under\nVersioning.\n",
        "enum": [
          "public",
          "subscribers"
        ]
      },
      "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 in particular did.\n\nPopulated on a change made through this API's write operations, where it is the credential, and on a change one of the newsletter's own automations made, where it is the run. 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 was attributed\", never as \"nothing caused this\".\n\nIt never names a person: an `api_key` actor is the credential and a `system` actor is an automation run.\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. `system` for a change one of the newsletter's own automations made, where `id` is the automation run and `label` names the automation: see `automation.run.started` for what that attribution is for. `user` is reserved for a change a named person made through an authenticated session and is not 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"
              }
            }
          }
        ]
      },
      "AutomationRunEventData": {
        "type": "object",
        "title": "Automation run event body",
        "description": "Shared body of the three `automation.run.*` topics: which run, of which automation, about which person, started by which event.\n\nA run is one subscriber's passage through one automation, so these identifiers describe a passage and not a configuration. The automation itself is at `GET /automations/{automation}` and the run's step history at `GET /runs/{run}`.",
        "required": [
          "run_id",
          "automation_id",
          "automation_name",
          "trigger",
          "subscriber_id",
          "event_id",
          "chain_depth",
          "started_at"
        ],
        "properties": {
          "run_id": {
            "type": "string",
            "format": "uuid",
            "description": "Commune's identifier for this run, and the value `actor.id` carries on this message and on every event this run's own actions cause."
          },
          "automation_id": {
            "type": "string",
            "format": "uuid",
            "description": "The automation this run is a passage through."
          },
          "automation_name": {
            "type": "string",
            "description": "The automation's name as the creator last saved it, so a consumer can report the run without a second call. Renaming the automation does not rewrite the events already delivered."
          },
          "trigger": {
            "type": "string",
            "description": "The event topic the automation is built on, which is the topic of the event that started this run. One of the topics in this `webhooks` block: every topic Commune publishes is a trigger.",
            "examples": [
              "subscriber.created"
            ]
          },
          "subscriber_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The person the run is about. Null when the run's trigger is not about a person, which is every topic outside the `subscriber.*` and `delivery.*` families. Such a run can read its event and wait, but it has nobody to email or tag."
          },
          "event_id": {
            "type": "string",
            "format": "uuid",
            "description": "The envelope `id` of the event that started the run. The same value a consumer saw on that message, so a run can be tied back to the change that caused it. `UNIQUE (automation_id, event_id)` is what stops one event starting the same automation twice, so this pair is also the run's natural key."
          },
          "chain_depth": {
            "type": "integer",
            "minimum": 0,
            "description": "How many automations' actions led to this run. Zero for a run caused by a person or by Commune itself, one for a run started by what another automation did, and so on. Five is the limit: a sixth hop is refused rather than started, and so is any hop that would re-enter an automation already in the chain.\n\nThe chain itself is not in the payload. `actor.id` names the run that caused the event this run started from, so a consumer that keeps these messages can walk the chain back one hop per message."
          },
          "started_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the run was created. Equal to `occurred_at` on `automation.run.started`, and earlier than it on the other two."
          }
        }
      },
      "AutomationRunCompletedEvent": {
        "title": "Automation run completed",
        "description": "A run reached the end of its graph.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "automation.run.completed"
              },
              "data": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/AutomationRunEventData"
                  },
                  {
                    "type": "object",
                    "required": [
                      "completed_at"
                    ],
                    "properties": {
                      "completed_at": {
                        "type": "string",
                        "format": "date-time",
                        "description": "When the run ended. Equal to `occurred_at`."
                      }
                    }
                  }
                ]
              }
            }
          }
        ]
      },
      "AutomationRunExitedEvent": {
        "title": "Automation run ended early",
        "description": "A run stopped before the end of its graph, deliberately or because Commune gave up advancing it.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "automation.run.exited"
              },
              "data": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/AutomationRunEventData"
                  },
                  {
                    "type": "object",
                    "required": [
                      "completed_at",
                      "status",
                      "reason"
                    ],
                    "properties": {
                      "completed_at": {
                        "type": "string",
                        "format": "date-time",
                        "description": "When the run ended. Equal to `occurred_at`."
                      },
                      "status": {
                        "type": "string",
                        "enum": [
                          "exited",
                          "failed"
                        ],
                        "description": "`exited` is an ordinary early ending and not an error: the subscriber left the list, a branch ran into a `stop`, the creator archived the automation, or an edit removed the step the run was on. `failed` is a run Commune could not advance and has stopped retrying."
                      },
                      "reason": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "maxLength": 1000,
                        "description": "A short Commune-chosen code when `status` is `exited`, and the last error text when it is `failed`. The set of codes grows as the engine learns new ways to stop, and a new one is not a breaking change, so treat an unrecognised value as \"ended, for a reason I do not handle\".",
                        "examples": [
                          "subscriber_unsubscribed"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        ]
      },
      "AutomationRunStartedEvent": {
        "title": "Automation run started",
        "description": "An event matched a live automation's trigger and Commune created a run.",
        "allOf": [
          {
            "$ref": "#/components/schemas/EventEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "const": "automation.run.started"
              },
              "data": {
                "$ref": "#/components/schemas/AutomationRunEventData"
              }
            }
          }
        ]
      },
      "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 email to one\nrecipient, identified by both Commune's ids and the provider's message\nid, so a consumer can reconcile with its own provider logs.\n\n**`source` says what caused the email, and decides which pair of ids\nis filled in.** `article_send` is an article the creator sent to the\nlist, and carries `article_id` and `send_id`. `automation` is one\n`send_email` step of one automation run, and carries `run_id` and\n`step_id`. The other pair is null. Branch on `source` rather than on\nwhich ids are present: a new source is not a breaking change and would\nleave both pairs null.\n\n**Breaking change, 2026-10-13.** `article_id` and `send_id` used to be\nrequired and non-null, because an article send was the only thing\nCommune could email. Automations now email too, and an automation\ndelivery has neither id. Publishing it under invented values was the\nalternative and was worse: a consumer would resolve ids that do not\nexist. Publishing it on its own five topics was the other, and was\nrejected because a consumer wanting every open would have to subscribe\nto ten topics and one written today would silently stop hearing about\nhalf its creator's email the day the creator switched a sequence on.\nSo the ids are nullable and `source` is the discriminator. A consumer\nthat dereferences `article_id` unconditionally has to check `source`\nfirst.\n",
        "required": [
          "delivery_id",
          "source",
          "subscriber_id",
          "email",
          "status"
        ],
        "properties": {
          "delivery_id": {
            "type": "string",
            "format": "uuid",
            "description": "Commune's identifier for this delivery."
          },
          "source": {
            "type": "string",
            "enum": [
              "article_send",
              "automation"
            ],
            "description": "What caused the email. `article_send` for an article the creator sent to the list, `automation` for one step of one automation run. The discriminator for the two id pairs below."
          },
          "article_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The article that was sent. Null when `source` is `automation`: an automation email is not an article and is not readable at `GET /articles/{article}`."
          },
          "send_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The send run this delivery belonged to. Null when `source` is `automation`: an automation email is not part of a send to the list, so there is no send run to name."
          },
          "run_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The automation run that sent the email, readable at `GET /runs/{run}`. Null when `source` is `article_send`."
          },
          "step_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The `send_email` step of that run's graph, as the creator named it. Stable across edits of the automation, so it is the right key for \"how did the second email of the sequence do\". Null when `source` is `article_send`."
          },
          "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"
              }
            }
          }
        ]
      },
      "AutomationStatus": {
        "type": "string",
        "title": "AutomationStatus",
        "description": "Where an automation is in its life.\n\n`draft` is where every automation starts and the only status a create can\nproduce. `active` is the one status that sends: the engine's ingest and its\nclaim on due work both filter on it, so nothing an automation holds reaches\nanybody until somebody turns it on.\n\n`paused` holds every waiting run exactly where it is. A run three days into a\nsequence stays on that step and resumes from it when the automation is turned\non again, so a pause is instant and total rather than a drain.\n\n`archived` is what `DELETE` leaves behind. It is not sending and it is not\ncoming back: the runs it was in the middle of are ended, because a run holding\nits place on a graph nobody will activate again is a subscriber stuck part way\nthrough forever. The automation and its history stay readable, which is the\nreason it is archived rather than deleted.\n",
        "enum": [
          "draft",
          "active",
          "paused",
          "archived"
        ]
      },
      "AutomationRunStatus": {
        "type": "string",
        "title": "AutomationRunStatus",
        "description": "Where one subscriber is in one automation.\n\n`running` is due now, `waiting` is parked on a `wait` step until its time. The\nthree terminal ones are not interchangeable and the difference is worth\nreading: `completed` reached the end of its path, `exited` stopped short for a\nreason that is not an error, and `failed` could not be advanced after repeated\nattempts and will not be tried again.\n\nAn exit is the ordinary outcome for somebody who unsubscribed, bounced or\ncomplained part way through, which the engine checks immediately before every\nsend rather than trusting what it saw when the run began. `exit_reason` says\nwhich.\n",
        "enum": [
          "running",
          "waiting",
          "completed",
          "exited",
          "failed"
        ]
      },
      "AutomationStepType": {
        "type": "string",
        "title": "AutomationStepType",
        "description": "What a step does.\n\n`send_email` sends the email it carries. `wait` parks the run for a duration.\n`add_tag` and `remove_tag` move a tag on the subscriber the run is about.\n`condition` evaluates a schema and takes `then` or `else`.\n\n`stop` ends the run where it is, and it is graph punctuation rather than an\naction. It exists because `next` defaults to the following step in order, so\nwithout it the `then` arm of a condition falls through into the `else` arm's\nsteps and the subscriber receives both emails.\n",
        "enum": [
          "send_email",
          "wait",
          "add_tag",
          "remove_tag",
          "condition",
          "stop"
        ]
      },
      "AutomationTriggerTopic": {
        "type": "string",
        "title": "AutomationTriggerTopic",
        "description": "The event that starts a run.\n\nEvery topic Commune publishes is a trigger, including the ones that are not\nabout a subscriber. An automation on `article.published` can hold conditions\nand waits; it cannot send an email or move a tag, because there is nobody for\nthose to act on, and saving one that tries is refused rather than left to fail\nonce per event forever.\n\n`GET /automation-triggers` says, per topic, which properties its events carry\nand whether it is about a subscriber. That is what a condition can ask about.\n",
        "enum": [
          "article.liked",
          "article.published",
          "article.read",
          "article.scheduled",
          "automation.run.completed",
          "automation.run.exited",
          "automation.run.started",
          "billing.subscription.updated",
          "delivery.bounced",
          "delivery.clicked",
          "delivery.complained",
          "delivery.delivered",
          "delivery.opened",
          "domain.verified",
          "highlight.created",
          "import.completed",
          "message.created",
          "send.completed",
          "send.failed",
          "sender.verified",
          "subscriber.created",
          "subscriber.status_changed",
          "subscriber.tagged",
          "subscriber.unsubscribed",
          "thread.created",
          "thread.published"
        ]
      },
      "AutomationEmailRequest": {
        "type": "object",
        "title": "AutomationEmailRequest",
        "description": "The email a `send_email` step sends, inline on the step.\n\nInline rather than by id, because an automation email has no life of its own:\nit is not listed, not addressable and not reusable between steps, so an id\nwould be an identifier for something you can never hold.\n\nThe body is Markdown and the parser is strict. A body it cannot read is a `400`\nnaming the line, and nothing is written. That matters more here than for an\narticle: an article is looked at before it goes out, and an automation email is\nwritten once and then sent unattended for months, so a guess becomes a month of\nmail nobody checked.\n\nTwo gates run on this body before it is stored, and again when the automation\nis turned on. It has to carry an unsubscribe link and a mailing address, which\nCommune seeds into every draft as ordinary content and which can therefore be\nwritten away. And every image in it has to load: an inbox fetches images fresh\nwhen the reader opens the email, so one that has rotted is broken for every\nrecipient at once and cannot be repaired afterwards.\n",
        "additionalProperties": false,
        "required": [
          "subject",
          "body_markdown"
        ],
        "properties": {
          "subject": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "description": "The subject line. Personalization placeholders work here as they do in the\nbody, so `{{ subscriber.first_name }}` is resolved per recipient.\n"
          },
          "preview_text": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500,
            "description": "The line an inbox shows after the subject, or null for none."
          },
          "body_markdown": {
            "type": "string",
            "maxLength": 500000,
            "description": "The email, as Markdown. Commune's own components are written in an\nMDX-shaped syntax; an unrecognised one is refused rather than passed\nthrough as text.\n"
          }
        },
        "examples": [
          {
            "subject": "Welcome, {{ subscriber.first_name }}",
            "preview_text": "Here is where to start.",
            "body_markdown": "# Glad you are here\n\nReply to this email and tell me what you are working on.\n\n[Unsubscribe]({{ unsubscribe_url }}) · {{ address }}\n"
          }
        ]
      },
      "AutomationStepRequest": {
        "type": "object",
        "title": "AutomationStepRequest",
        "description": "One step of the graph.\n\n**Only the fields your `type` uses.** A `wait` takes `duration`, a tag step\ntakes `tag`, a `condition` takes `schema` with `then` and `else`, a\n`send_email` takes `email`, and a `stop` takes none of them. Sending a field\nyour type does not use is refused rather than ignored, because a field quietly\ndropped is something you believe you wrote and the engine will never see.\n`null` is accepted wherever a field is not used, so a body built by echoing a\nresponse back works unchanged.\n\nThis is one schema rather than a union of six, deliberately. A body that fails\na six-branch union reports six failures and names none of them as the branch\nyou meant; the refusal here names the one rule that was broken.\n\n**`next` defaults to the following step in the array**, so a linear sequence\nneeds no wiring at all. Set it to jump, or use a `stop` step to end a path.\n",
        "additionalProperties": false,
        "required": [
          "id",
          "type"
        ],
        "properties": {
          "id": {
            "type": "string",
            "maxLength": 100,
            "pattern": "^[a-z0-9][a-z0-9_-]*$",
            "description": "You choose it, and it is stable. An edge points at it and a waiting run\nsits on it, so changing it is removing one step and adding another:\nwhatever was waiting on the old id has to be told where to go.\n"
          },
          "type": {
            "$ref": "#/components/schemas/AutomationStepType"
          },
          "next": {
            "type": [
              "string",
              "null"
            ],
            "description": "The step to go to next, or null for the following step in the array. Refused\non a `stop` step, which ends the run.\n"
          },
          "duration": {
            "type": [
              "string",
              "null"
            ],
            "description": "`wait` only. An ISO 8601 duration: `PT30M`, `PT6H`, `P3D`, `P2W`. Durations\nonly, and never a time of day: Commune does not know a subscriber's time\nzone, so a time of day would be UTC and would land in the middle of the\nnight for much of any real audience. At most 730 days.\n"
          },
          "tag": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "`add_tag` and `remove_tag` only. The `id` of one of this newsletter's tags.\nA tag that has been deleted is refused: a step naming one would do nothing,\nevery run, forever.\n"
          },
          "schema": {
            "type": [
              "object",
              "null"
            ],
            "description": "`condition` only. A JSON Schema document in Commune's condition subset,\nevaluated against the run's context to choose between `then` and `else`.\n\nThe subset is closed: `type`, `const`, `enum`, `properties`, `required`,\n`contains`, `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`,\n`maxItems`, `allOf`, `anyOf`, `oneOf` and `not`. Anything else is refused\nrather than ignored, which is the inversion that matters. A general\nvalidator ignores a keyword it does not implement, so an unsupported\nkeyword becomes a constraint you wrote and nothing enforces.\n\nThree refusals are worth knowing the reason for. `pattern` and\n`patternProperties` would run your regular expression against text a\nsubscriber controls. `$ref` and `$id` can be a remote fetch, and this runs\nin a server loop. `if`/`then`/`else` collide with this step's own branch\nfields, and `anyOf` and `not` already cover them.\n\nPaths are checked too. A condition about a property no payload carries\nmatches every run, so the branch would always take the same arm; such a\npath is refused at save time, naming it. `GET /automation-triggers` lists\nwhat each trigger's context carries.\n"
          },
          "then": {
            "type": [
              "string",
              "null"
            ],
            "description": "`condition` only. The step to go to when the schema matches, or null to fall\nthrough to the following step. A `then` arm that should not continue into\nthe `else` arm's steps ends with a `stop`.\n"
          },
          "else": {
            "type": [
              "string",
              "null"
            ],
            "description": "`condition` only. The step to go to when the schema does not match, or null\nto fall through to the following step.\n"
          },
          "email": {
            "description": "`send_email` only. The email this step sends.",
            "anyOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/AutomationEmailRequest"
              }
            ]
          }
        },
        "examples": [
          {
            "id": "welcome",
            "type": "send_email",
            "email": {
              "subject": "Welcome",
              "body_markdown": "Thanks for subscribing.\n\n[Unsubscribe]({{ unsubscribe_url }}) · {{ address }}\n"
            }
          },
          {
            "id": "settle",
            "type": "wait",
            "duration": "P3D"
          },
          {
            "id": "vip",
            "type": "condition",
            "schema": {
              "properties": {
                "subscriber": {
                  "properties": {
                    "tags": {
                      "contains": {
                        "const": "aa11bb22-cc33-4d44-8e55-ff6677889900"
                      }
                    }
                  }
                }
              }
            },
            "then": "extra",
            "else": "done"
          }
        ]
      },
      "AutomationCreateRequest": {
        "type": "object",
        "title": "AutomationCreateRequest",
        "description": "A whole automation: what starts it, and what it does.\n\nIt is created `draft` and there is no property that changes that. Nothing a\ncreate writes can email anybody; turning it on is `POST\n/automations/{automation}/activate`, which runs every gate again and is the one\noperation here charged to a daily cap.\n",
        "additionalProperties": false,
        "required": [
          "name",
          "trigger",
          "steps"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "How the automation is listed. Never shown to a subscriber."
          },
          "trigger": {
            "$ref": "#/components/schemas/AutomationTriggerTopic"
          },
          "trigger_filter": {
            "type": [
              "object",
              "null"
            ],
            "description": "A JSON Schema document in the same condition subset, evaluated against the\nevent's own payload to decide whether that event starts a run at all. Null\nfor no filter, which is the default.\n\nThe paths here are the payload's own, not the three-branch context a\n`condition` step is evaluated against, because a filter runs before there is\na run to have a context.\n"
          },
          "reentry": {
            "type": "boolean",
            "default": false,
            "description": "Whether one subscriber may go through this automation more than once.\n\nFalse, the default, means once ever, however many times the trigger fires\nfor them. A resubscribe never re-enters either, whatever this says: an\naddress coming back to a list it was on before does not get a first welcome\nemail for the second time.\n"
          },
          "steps": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "description": "Every step, in the order they run. `next` defaults to the following entry,\nso a linear sequence needs no wiring.\n",
            "items": {
              "$ref": "#/components/schemas/AutomationStepRequest"
            }
          }
        },
        "examples": [
          {
            "name": "Welcome sequence",
            "trigger": "subscriber.created",
            "reentry": false,
            "steps": [
              {
                "id": "welcome",
                "type": "send_email",
                "email": {
                  "subject": "Welcome, {{ subscriber.first_name }}",
                  "body_markdown": "Glad you are here.\n\n[Unsubscribe]({{ unsubscribe_url }}) · {{ address }}\n"
                }
              },
              {
                "id": "settle",
                "type": "wait",
                "duration": "P3D"
              },
              {
                "id": "best-of",
                "type": "send_email",
                "email": {
                  "subject": "Three things worth reading",
                  "body_markdown": "Here is where to start.\n\n[Unsubscribe]({{ unsubscribe_url }}) · {{ address }}\n"
                }
              }
            ]
          }
        ]
      },
      "AutomationUpdateRequest": {
        "type": "object",
        "title": "AutomationUpdateRequest",
        "description": "The properties to change. Every one is optional, and what you do not send is\nleft alone.\n\n**`steps` replaces the whole graph.** There is no operation that adds one step,\nbecause a step is only meaningful against the edges pointing at it: a surface\nthat let one be added alone would be a surface on which \"every edge points at a\nstep that exists\" is false between two requests, with a live automation running\nin the gap. Leaving `steps` out leaves the graph untouched, which is what a\nrename is.\n\n**An edit applies to runs already in flight.** A run sits on a step id, so a\nsubscriber part way through picks up the new steps from wherever they are. That\nis the point, and it is also why removing a step can be refused: see\n`on_removed_step`.\n",
        "additionalProperties": false,
        "minProperties": 1,
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "trigger": {
            "allOf": [
              {
                "$ref": "#/components/schemas/AutomationTriggerTopic"
              }
            ],
            "description": "Changing it requires sending `steps` as well. A trigger decides what\na condition may ask about and whether a step has anybody to act on,\nso a graph that was valid under one trigger may not be under\nanother, and Commune checks the graph you send rather than guessing\nthat the stored one still holds. Sending back the `steps` you just\nread is accepted unchanged.\n"
          },
          "trigger_filter": {
            "type": [
              "object",
              "null"
            ]
          },
          "reentry": {
            "type": "boolean"
          },
          "steps": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "items": {
              "$ref": "#/components/schemas/AutomationStepRequest"
            }
          },
          "on_removed_step": {
            "type": "string",
            "description": "What should happen to the runs waiting on a step this edit removes.\n\nWithout it, such an edit answers `409` naming how many runs are waiting and\non which steps. A run sits on a step id, so removing the step would leave\nthose subscribers part way through the sequence with nothing to advance them\nand nothing reporting it.\n\n`\"exit\"` ends those runs. Anything else is the `id` of a step in the graph\nyou are sending, and the runs move there and carry on from it; a target that\nis not in the new graph is refused, because moving a run to a step that is\nalso being removed would strand it again.\n\nIgnored when the edit removes no step that anything is waiting on, and\nrefused when the request sends no `steps` at all, since then it removes\nnothing.\n"
          }
        },
        "examples": [
          {
            "name": "Welcome sequence (v2)"
          },
          {
            "on_removed_step": "exit",
            "steps": [
              {
                "id": "welcome",
                "type": "send_email",
                "email": {
                  "subject": "Welcome",
                  "body_markdown": "Glad you are here.\n\n[Unsubscribe]({{ unsubscribe_url }}) · {{ address }}\n"
                }
              }
            ]
          }
        ]
      },
      "AutomationStepTestRequest": {
        "type": "object",
        "title": "AutomationStepTestRequest",
        "description": "Where to send the copy. Leave the whole body out, or leave `to` out, to send it\nto the address on the account this credential belongs to.\n",
        "additionalProperties": false,
        "properties": {
          "to": {
            "type": "array",
            "minItems": 1,
            "maxItems": 5,
            "description": "The addresses to send the copy to. At most five: it is a look before the\nsequence goes live rather than a way to reach a group.\n",
            "items": {
              "type": "string",
              "format": "email"
            }
          }
        },
        "examples": [
          {
            "to": [
              "me@example.com"
            ]
          }
        ]
      },
      "AutomationEmail": {
        "type": "object",
        "title": "AutomationEmail",
        "description": "The email a `send_email` step sends, as it is stored.\n\n`body_markdown` is the same rendition you sent, so reading an automation,\nchanging one step and sending the graph back is a round trip that preserves\neverything else. Personalization placeholders come back as you wrote them for\nthat reason: resolving them here would silently replace every greeting in the\nautomation with its fallback the next time somebody saved it.\n",
        "additionalProperties": false,
        "required": [
          "subject",
          "preview_text",
          "body_markdown"
        ],
        "properties": {
          "subject": {
            "type": "string"
          },
          "preview_text": {
            "type": [
              "string",
              "null"
            ]
          },
          "body_markdown": {
            "type": [
              "string",
              "null"
            ],
            "description": "The body as Markdown, or null for a stored document this API cannot render\nas Markdown faithfully.\n"
          }
        }
      },
      "AutomationStep": {
        "type": "object",
        "title": "AutomationStep",
        "description": "One step of the graph.\n\nEvery field is present on every step, `null` where the type does not use it.\nThat is the opposite of the request shape, which refuses a field a type does not\nuse, and the asymmetry is deliberate: a reader wants one key set to narrow, and\na writer wants to be told when it has said something meaningless. A body built\nby sending this back is accepted unchanged.\n",
        "additionalProperties": false,
        "required": [
          "id",
          "type",
          "next",
          "then",
          "else",
          "duration",
          "tag",
          "schema",
          "email"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "type": {
            "$ref": "#/components/schemas/AutomationStepType"
          },
          "next": {
            "type": [
              "string",
              "null"
            ],
            "description": "The step this one goes to, or null to fall through to the next step in order.\nNull is the common case: a linear sequence wires nothing.\n"
          },
          "then": {
            "type": [
              "string",
              "null"
            ]
          },
          "else": {
            "type": [
              "string",
              "null"
            ]
          },
          "duration": {
            "type": [
              "string",
              "null"
            ]
          },
          "tag": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "schema": {
            "type": [
              "object",
              "null"
            ]
          },
          "email": {
            "anyOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/AutomationEmail"
              }
            ]
          }
        }
      },
      "Automation": {
        "type": "object",
        "title": "Automation",
        "description": "One automation, with its whole graph.\n\nAn event happens, and an automation that triggers on it starts a run for the\nsubscriber that event is about. The run walks the graph: sending, waiting,\nmoving tags, branching on a condition, until it reaches the end or stops short.\n\nNothing about it reaches anybody until `status` is `active`.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "name",
          "status",
          "trigger",
          "trigger_filter",
          "reentry",
          "steps",
          "created_at",
          "updated_at",
          "activated_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "automation",
            "description": "Always `automation`."
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "newsletter": {
            "description": "The newsletter this automation belongs to. A `Ref` unless `newsletter` is\nnamed in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "name": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/AutomationStatus"
          },
          "trigger": {
            "$ref": "#/components/schemas/AutomationTriggerTopic"
          },
          "trigger_filter": {
            "type": [
              "object",
              "null"
            ]
          },
          "reentry": {
            "type": "boolean"
          },
          "steps": {
            "type": "array",
            "description": "The graph, in the order the steps run.",
            "items": {
              "$ref": "#/components/schemas/AutomationStep"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "activated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When this automation was last turned on, or null if it never has been. It is\nnot cleared by pausing, so it answers \"has this ever sent anything\".\n"
          }
        }
      },
      "AutomationSummary": {
        "type": "object",
        "title": "AutomationSummary",
        "description": "An automation without its graph, which is what a page of them carries.\n\nA `send_email` step holds a whole email body, so a page of automations with\ntheir graphs inlined would be a newsletter's worth of prose in the answer to\n\"what automations do I have\". `step_count` is enough to tell an empty draft from\na built sequence; `GET /automations/{automation}` has the graph.\n\n`object` differs from `Automation`'s, so a row always says which shape it is. One\ndiscriminator for both, with `steps` sometimes absent, would make \"this\nautomation has no steps\" and \"this is the short form\" the same response.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "newsletter",
          "name",
          "status",
          "trigger",
          "trigger_filter",
          "reentry",
          "step_count",
          "created_at",
          "updated_at",
          "activated_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "automation_summary",
            "description": "Always `automation_summary`."
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "newsletter": {
            "description": "The newsletter this automation belongs to. A `Ref` unless `newsletter` is\nnamed in `?expand=`.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/Newsletter"
              }
            ]
          },
          "name": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/AutomationStatus"
          },
          "trigger": {
            "$ref": "#/components/schemas/AutomationTriggerTopic"
          },
          "trigger_filter": {
            "type": [
              "object",
              "null"
            ]
          },
          "reentry": {
            "type": "boolean"
          },
          "step_count": {
            "type": "integer",
            "minimum": 0,
            "description": "How many steps the graph has."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "activated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "AutomationRunStep": {
        "type": "object",
        "title": "AutomationRunStep",
        "description": "What one `send_email` step did for one subscriber.\n\nOne entry per step that reached the point of sending, so a run that is part way\nthrough has fewer entries than its automation has email steps, and a step the\nrun branched away from has none at all.\n\nIt carries no address. The address this copy went to is on the record Commune\nkeeps, and it is not served here: a run is read under `sending`, and an address\nis read under `audience`. Resolve the run's `subscriber` to get it.\n",
        "additionalProperties": false,
        "required": [
          "step",
          "status",
          "failure_reason",
          "sent_at",
          "delivered_at",
          "opened_at",
          "clicked_at",
          "bounced_at",
          "complained_at"
        ],
        "properties": {
          "step": {
            "type": "string",
            "description": "The `id` of the step in the automation's graph."
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "sent",
              "delivered",
              "opened",
              "clicked",
              "bounced",
              "complained",
              "failed"
            ],
            "description": "How far this one email got. It only moves forward: a `complained` is never\nwalked back, and an open arriving after a click does not undo the click.\n\n`queued` means Commune claimed the slot and never confirmed the hand-over,\nwhich is a send it cannot vouch for. It is never retried automatically --\nthat is the one move that could deliver twice -- and is settled as `failed`\nonce it is clearly stale.\n"
          },
          "failure_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "sent_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "delivered_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "opened_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "clicked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When a link in this email was first clicked."
          },
          "bounced_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "complained_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "AutomationRun": {
        "type": "object",
        "title": "AutomationRun",
        "description": "One subscriber's journey through one automation.\n\nIt is how you answer \"did this sequence reach that person, and where are they\nin it\". `status` and `current_step` say where they are, `exit_reason` says why\nthey stopped if they did, and `steps` is what each email did.\n\nOne run per subscriber per triggering event, and one per subscriber in total\nunless the automation allows re-entry. A run is never started twice for the\nsame event.\n\nIt carries no email address and no copy of the event that started it. Both are\ndeliberate: a run is read under `sending`, and an address belongs to\n`audience`. Resolve `subscriber` to read one.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "id",
          "automation",
          "subscriber",
          "status",
          "current_step",
          "exit_reason",
          "attempts",
          "steps",
          "next_run_at",
          "created_at",
          "completed_at"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "automation_run",
            "description": "Always `automation_run`."
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "automation": {
            "description": "The automation this run is of. A `Ref` unless `automation` is named in\n`?expand=`, which inlines it without its graph.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Ref"
              },
              {
                "$ref": "#/components/schemas/AutomationSummary"
              }
            ]
          },
          "subscriber": {
            "description": "The subscriber this run is about, or null for a trigger whose events are not\nabout one. Always a `Ref`: `?expand=subscriber` is not accepted, because a\n`Subscriber` carries an email address and this operation asks for `sending`\nrather than `audience`. `GET /subscribers/{subscriber}` resolves it.\n",
            "anyOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/Ref"
              }
            ]
          },
          "status": {
            "$ref": "#/components/schemas/AutomationRunStatus"
          },
          "current_step": {
            "type": [
              "string",
              "null"
            ],
            "description": "The `id` of the step this run is on, or null once it has finished. A run that\nis `waiting` sits here until `next_run_at`.\n"
          },
          "exit_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why the run stopped short, for an `exited` one. The common ones are the\nsubscriber's own: they unsubscribed, bounced or complained during a wait,\nwhich Commune re-checks immediately before every send rather than trusting\nwhat it saw when the run began.\n"
          },
          "attempts": {
            "type": "integer",
            "minimum": 0,
            "description": "How many times Commune has tried to advance this run. It counts advances\nrather than emails, so a long sequence has many and a failing one has many\nfor a different reason.\n"
          },
          "steps": {
            "type": "array",
            "description": "What each of the automation's `send_email` steps did for this subscriber, oldest first.",
            "items": {
              "$ref": "#/components/schemas/AutomationRunStep"
            }
          },
          "next_run_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When this run is next due, or null once it has finished."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the triggering event started this run."
          },
          "completed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "AutomationTrigger": {
        "type": "object",
        "title": "AutomationTrigger",
        "description": "One event an automation can be built on, and what a condition may ask about\nunder it.\n\nThe same for every newsletter, because it describes Commune's event vocabulary\nrather than anybody's data.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "topic",
          "carries_subscriber",
          "context"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "automation_trigger",
            "description": "Always `automation_trigger`."
          },
          "topic": {
            "$ref": "#/components/schemas/AutomationTriggerTopic"
          },
          "carries_subscriber": {
            "type": "boolean",
            "description": "Whether this topic's events are about a subscriber. False means an automation\non it cannot send an email or move a tag, because there is nobody for those\nto act on, and saving one that tries is refused.\n"
          },
          "context": {
            "$ref": "#/components/schemas/AutomationConditionContext"
          }
        }
      },
      "AutomationStepTest": {
        "type": "object",
        "title": "AutomationStepTest",
        "description": "What became of a test copy of one step's email.\n\nIt touches nothing: no run, no delivery record, nobody on the list. Run it\ntwice and the only thing that happens twice is somebody reading the email.\n\nPersonalization placeholders are rendered against example values, and the\nunsubscribe link points at a page that explains itself rather than at a real\ntoken, so a forwarded copy cannot unsubscribe anybody.\n",
        "additionalProperties": false,
        "required": [
          "object",
          "automation",
          "step",
          "subject",
          "recipients",
          "sent_to_owner",
          "sent",
          "failed"
        ],
        "properties": {
          "object": {
            "type": "string",
            "const": "automation_step_test",
            "description": "Always `automation_step_test`."
          },
          "automation": {
            "$ref": "#/components/schemas/Ref"
          },
          "step": {
            "type": "string",
            "description": "The `id` of the step whose email was sent."
          },
          "subject": {
            "type": [
              "string",
              "null"
            ],
            "description": "The subject as it rendered, which is what arrived in the inbox."
          },
          "recipients": {
            "type": "array",
            "description": "The addresses this request named, deduplicated and in the order given. Empty\nwhen `to` was left out: the account's own address is not echoed, because it\nis revealed only on `GET /me` under `account: read`.\n",
            "items": {
              "type": "string"
            }
          },
          "sent_to_owner": {
            "type": "boolean",
            "description": "True when `to` was left out and the copy went to the address on the account\nthis credential belongs to.\n"
          },
          "sent": {
            "type": "integer",
            "minimum": 0,
            "description": "How many addresses the sending provider accepted."
          },
          "failed": {
            "type": "integer",
            "minimum": 0,
            "description": "How many it refused."
          }
        }
      },
      "AutomationConditionContext": {
        "title": "AutomationConditionContext",
        "description": "What a `condition` step may ask about, for one trigger. One variant per\ntopic, because `event` is that topic's own payload: a condition about the\nevent is written against the same properties the topic's webhook delivers.\n\nEach variant lists the property **names** a condition may use under each of\nthe three branches, rather than their types. A condition is a JSON Schema\ndocument, and its own `type` keyword is what asserts what a value is; what\nthis answers is the question a schema cannot answer about itself, which is\nwhether the path it names can exist at all.\n\nThat question matters because of one asymmetry. JSON Schema treats an absent\nproperty as saying nothing, so a condition about a property no payload\ncarries matches every run: the branch silently always takes the same arm, and\nnothing anywhere reports it. Saving such a condition is refused, naming the\npath, and this is the list that refusal is computed from.\n\n`subscriber` is empty for a topic whose events are not about a subscriber.\nThat emptiness carries three facts at once: such a trigger cannot send an\nemail, cannot apply a tag, and cannot be asked anything about a person.\n\n`steps` is empty on every variant, and has to be: its names are an\nautomation's own `send_email` step ids, so they belong to an automation rather\nthan to a topic. `GET /automation-triggers` serves these, and the `steps` of a\nparticular automation are the ids in its own graph.\n",
        "oneOf": [
          {
            "$ref": "#/components/schemas/AutomationContextArticleLiked"
          },
          {
            "$ref": "#/components/schemas/AutomationContextArticlePublished"
          },
          {
            "$ref": "#/components/schemas/AutomationContextArticleRead"
          },
          {
            "$ref": "#/components/schemas/AutomationContextArticleScheduled"
          },
          {
            "$ref": "#/components/schemas/AutomationContextAutomationRunCompleted"
          },
          {
            "$ref": "#/components/schemas/AutomationContextAutomationRunExited"
          },
          {
            "$ref": "#/components/schemas/AutomationContextAutomationRunStarted"
          },
          {
            "$ref": "#/components/schemas/AutomationContextBillingSubscriptionUpdated"
          },
          {
            "$ref": "#/components/schemas/AutomationContextDeliveryBounced"
          },
          {
            "$ref": "#/components/schemas/AutomationContextDeliveryClicked"
          },
          {
            "$ref": "#/components/schemas/AutomationContextDeliveryComplained"
          },
          {
            "$ref": "#/components/schemas/AutomationContextDeliveryDelivered"
          },
          {
            "$ref": "#/components/schemas/AutomationContextDeliveryOpened"
          },
          {
            "$ref": "#/components/schemas/AutomationContextDomainVerified"
          },
          {
            "$ref": "#/components/schemas/AutomationContextHighlightCreated"
          },
          {
            "$ref": "#/components/schemas/AutomationContextImportCompleted"
          },
          {
            "$ref": "#/components/schemas/AutomationContextMessageCreated"
          },
          {
            "$ref": "#/components/schemas/AutomationContextSendCompleted"
          },
          {
            "$ref": "#/components/schemas/AutomationContextSendFailed"
          },
          {
            "$ref": "#/components/schemas/AutomationContextSenderVerified"
          },
          {
            "$ref": "#/components/schemas/AutomationContextSubscriberCreated"
          },
          {
            "$ref": "#/components/schemas/AutomationContextSubscriberStatusChanged"
          },
          {
            "$ref": "#/components/schemas/AutomationContextSubscriberTagged"
          },
          {
            "$ref": "#/components/schemas/AutomationContextSubscriberUnsubscribed"
          },
          {
            "$ref": "#/components/schemas/AutomationContextThreadCreated"
          },
          {
            "$ref": "#/components/schemas/AutomationContextThreadPublished"
          }
        ]
      },
      "AutomationConditionSubscriberPaths": {
        "type": "array",
        "title": "AutomationConditionSubscriberPaths",
        "description": "The live subscriber fields a condition may read, for a trigger whose events\nare about a subscriber. Read at evaluation time rather than when the run\nstarted, which is the whole reason a condition is useful: asking whether\nsomebody holds a tag means now, three days into the sequence, and not when\nthey subscribed.\n",
        "items": {
          "type": "string",
          "enum": [
            "acquired_via_commune",
            "acquisition_source",
            "created_at",
            "email",
            "id",
            "status",
            "subscribed_via",
            "tags"
          ]
        }
      },
      "AutomationConditionStepPaths": {
        "type": "array",
        "title": "AutomationConditionStepPaths",
        "description": "The ids of the automation's `send_email` steps, each of which reports\nclicked and opened once its delivery has been attributed. Empty on the\ntrigger catalog, where there is no automation and therefore no step.\n",
        "items": {
          "type": "string"
        }
      },
      "AutomationContextArticleLiked": {
        "type": "object",
        "title": "article.liked context",
        "description": "What a condition may ask about under an automation triggered by `article.liked`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "article.liked"
          },
          "event": {
            "type": "array",
            "description": "The properties `article.liked` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "changed_at",
                "direction",
                "like_count",
                "reader",
                "title",
                "url"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `article.liked` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextArticlePublished": {
        "type": "object",
        "title": "article.published context",
        "description": "What a condition may ask about under an automation triggered by `article.published`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "article.published"
          },
          "event": {
            "type": "array",
            "description": "The properties `article.published` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "audience_scoped",
                "published_at",
                "slug",
                "source",
                "title",
                "url"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `article.published` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextArticleRead": {
        "type": "object",
        "title": "article.read context",
        "description": "What a condition may ask about under an automation triggered by `article.read`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "article.read"
          },
          "event": {
            "type": "array",
            "description": "The properties `article.read` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "read_at",
                "reader",
                "title",
                "url"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `article.read` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextArticleScheduled": {
        "type": "object",
        "title": "article.scheduled context",
        "description": "What a condition may ask about under an automation triggered by `article.scheduled`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "article.scheduled"
          },
          "event": {
            "type": "array",
            "description": "The properties `article.scheduled` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "scheduled_for",
                "title"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `article.scheduled` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextAutomationRunCompleted": {
        "type": "object",
        "title": "automation.run.completed context",
        "description": "What a condition may ask about under an automation triggered by `automation.run.completed`. A run has a subscriber only when its own trigger did, so these events are treated as not about a subscriber: an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "automation.run.completed"
          },
          "event": {
            "type": "array",
            "description": "The properties `automation.run.completed` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "automation_id",
                "automation_name",
                "chain_depth",
                "completed_at",
                "event_id",
                "run_id",
                "started_at",
                "subscriber_id",
                "trigger"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty, and deliberately so even though the payload carries a `subscriber_id`: that field is null for a run whose own trigger was not about a person, so a trigger that promised a subscriber here would let a creator activate a sequence that mails nobody for half its runs. Read `event.subscriber_id` in a condition instead.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextAutomationRunExited": {
        "type": "object",
        "title": "automation.run.exited context",
        "description": "What a condition may ask about under an automation triggered by `automation.run.exited`. A run has a subscriber only when its own trigger did, so these events are treated as not about a subscriber: an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "automation.run.exited"
          },
          "event": {
            "type": "array",
            "description": "The properties `automation.run.exited` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "automation_id",
                "automation_name",
                "chain_depth",
                "completed_at",
                "event_id",
                "reason",
                "run_id",
                "started_at",
                "status",
                "subscriber_id",
                "trigger"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty, for the reason `automation.run.completed` gives: a run's own subscriber is optional, so this trigger cannot promise one.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextAutomationRunStarted": {
        "type": "object",
        "title": "automation.run.started context",
        "description": "What a condition may ask about under an automation triggered by `automation.run.started`. A run has a subscriber only when its own trigger did, so these events are treated as not about a subscriber: an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "automation.run.started"
          },
          "event": {
            "type": "array",
            "description": "The properties `automation.run.started` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "automation_id",
                "automation_name",
                "chain_depth",
                "event_id",
                "run_id",
                "started_at",
                "subscriber_id",
                "trigger"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty, for the reason `automation.run.completed` gives: a run's own subscriber is optional, so this trigger cannot promise one.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextBillingSubscriptionUpdated": {
        "type": "object",
        "title": "billing.subscription.updated context",
        "description": "What a condition may ask about under an automation triggered by `billing.subscription.updated`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "billing.subscription.updated"
          },
          "event": {
            "type": "array",
            "description": "The properties `billing.subscription.updated` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "cancel_at_period_end",
                "current_period_end",
                "current_period_start",
                "plan",
                "previous_status",
                "status",
                "subscription_id",
                "trial_ends_at"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `billing.subscription.updated` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextDeliveryBounced": {
        "type": "object",
        "title": "delivery.bounced context",
        "description": "What a condition may ask about under an automation triggered by `delivery.bounced`.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "delivery.bounced"
          },
          "event": {
            "type": "array",
            "description": "The properties `delivery.bounced` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "delivery_id",
                "email",
                "provider_message_id",
                "reason",
                "run_id",
                "send_id",
                "source",
                "status",
                "step_id",
                "subscriber_id"
              ]
            }
          },
          "subscriber": {
            "$ref": "#/components/schemas/AutomationConditionSubscriberPaths"
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextDeliveryClicked": {
        "type": "object",
        "title": "delivery.clicked context",
        "description": "What a condition may ask about under an automation triggered by `delivery.clicked`.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "delivery.clicked"
          },
          "event": {
            "type": "array",
            "description": "The properties `delivery.clicked` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "delivery_id",
                "email",
                "provider_message_id",
                "run_id",
                "send_id",
                "source",
                "status",
                "step_id",
                "subscriber_id"
              ]
            }
          },
          "subscriber": {
            "$ref": "#/components/schemas/AutomationConditionSubscriberPaths"
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextDeliveryComplained": {
        "type": "object",
        "title": "delivery.complained context",
        "description": "What a condition may ask about under an automation triggered by `delivery.complained`.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "delivery.complained"
          },
          "event": {
            "type": "array",
            "description": "The properties `delivery.complained` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "delivery_id",
                "email",
                "provider_message_id",
                "reason",
                "run_id",
                "send_id",
                "source",
                "status",
                "step_id",
                "subscriber_id"
              ]
            }
          },
          "subscriber": {
            "$ref": "#/components/schemas/AutomationConditionSubscriberPaths"
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextDeliveryDelivered": {
        "type": "object",
        "title": "delivery.delivered context",
        "description": "What a condition may ask about under an automation triggered by `delivery.delivered`.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "delivery.delivered"
          },
          "event": {
            "type": "array",
            "description": "The properties `delivery.delivered` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "delivery_id",
                "email",
                "provider_message_id",
                "run_id",
                "send_id",
                "source",
                "status",
                "step_id",
                "subscriber_id"
              ]
            }
          },
          "subscriber": {
            "$ref": "#/components/schemas/AutomationConditionSubscriberPaths"
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextDeliveryOpened": {
        "type": "object",
        "title": "delivery.opened context",
        "description": "What a condition may ask about under an automation triggered by `delivery.opened`.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "delivery.opened"
          },
          "event": {
            "type": "array",
            "description": "The properties `delivery.opened` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "delivery_id",
                "email",
                "provider_message_id",
                "run_id",
                "send_id",
                "source",
                "status",
                "step_id",
                "subscriber_id"
              ]
            }
          },
          "subscriber": {
            "$ref": "#/components/schemas/AutomationConditionSubscriberPaths"
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextDomainVerified": {
        "type": "object",
        "title": "domain.verified context",
        "description": "What a condition may ask about under an automation triggered by `domain.verified`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "domain.verified"
          },
          "event": {
            "type": "array",
            "description": "The properties `domain.verified` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "domain",
                "domain_id",
                "ssl_status",
                "verified_at"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `domain.verified` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextHighlightCreated": {
        "type": "object",
        "title": "highlight.created context",
        "description": "What a condition may ask about under an automation triggered by `highlight.created`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "highlight.created"
          },
          "event": {
            "type": "array",
            "description": "The properties `highlight.created` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "created_at",
                "end_offset",
                "highlight_id",
                "message_id",
                "owner_key",
                "quote",
                "start_offset"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `highlight.created` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextImportCompleted": {
        "type": "object",
        "title": "import.completed context",
        "description": "What a condition may ask about under an automation triggered by `import.completed`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "import.completed"
          },
          "event": {
            "type": "array",
            "description": "The properties `import.completed` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "completed_at",
                "imported_count",
                "kind",
                "source",
                "started_at"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `import.completed` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextMessageCreated": {
        "type": "object",
        "title": "message.created context",
        "description": "What a condition may ask about under an automation triggered by `message.created`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "message.created"
          },
          "event": {
            "type": "array",
            "description": "The properties `message.created` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "author",
                "content",
                "created_at",
                "message_id",
                "parent_id",
                "short_id",
                "thread_id",
                "thread_level",
                "url"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `message.created` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextSendCompleted": {
        "type": "object",
        "title": "send.completed context",
        "description": "What a condition may ask about under an automation triggered by `send.completed`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "send.completed"
          },
          "event": {
            "type": "array",
            "description": "The properties `send.completed` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "completed_at",
                "failed_count",
                "recipient_count",
                "send_id",
                "sent_count",
                "started_at"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `send.completed` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextSendFailed": {
        "type": "object",
        "title": "send.failed context",
        "description": "What a condition may ask about under an automation triggered by `send.failed`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "send.failed"
          },
          "event": {
            "type": "array",
            "description": "The properties `send.failed` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "failure_reason",
                "send_id"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `send.failed` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextSenderVerified": {
        "type": "object",
        "title": "sender.verified context",
        "description": "What a condition may ask about under an automation triggered by `sender.verified`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "sender.verified"
          },
          "event": {
            "type": "array",
            "description": "The properties `sender.verified` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "domain",
                "from_email",
                "from_name",
                "is_default",
                "kind",
                "reply_to_email",
                "sender_id",
                "verified_at"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `sender.verified` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextSubscriberCreated": {
        "type": "object",
        "title": "subscriber.created context",
        "description": "What a condition may ask about under an automation triggered by `subscriber.created`.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "subscriber.created"
          },
          "event": {
            "type": "array",
            "description": "The properties `subscriber.created` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "acquisition_source",
                "created_at",
                "email",
                "resubscribed",
                "status",
                "subscriber_id",
                "user_id"
              ]
            }
          },
          "subscriber": {
            "$ref": "#/components/schemas/AutomationConditionSubscriberPaths"
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextSubscriberStatusChanged": {
        "type": "object",
        "title": "subscriber.status_changed context",
        "description": "What a condition may ask about under an automation triggered by `subscriber.status_changed`.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "subscriber.status_changed"
          },
          "event": {
            "type": "array",
            "description": "The properties `subscriber.status_changed` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "direction",
                "email",
                "last_action_at",
                "previous_status",
                "status",
                "subscriber_id",
                "total_score",
                "user_id",
                "velocity"
              ]
            }
          },
          "subscriber": {
            "$ref": "#/components/schemas/AutomationConditionSubscriberPaths"
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextSubscriberTagged": {
        "type": "object",
        "title": "subscriber.tagged context",
        "description": "What a condition may ask about under an automation triggered by `subscriber.tagged`.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "subscriber.tagged"
          },
          "event": {
            "type": "array",
            "description": "The properties `subscriber.tagged` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "direction",
                "email",
                "subscriber_id",
                "tag_id",
                "tag_name"
              ]
            }
          },
          "subscriber": {
            "$ref": "#/components/schemas/AutomationConditionSubscriberPaths"
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextSubscriberUnsubscribed": {
        "type": "object",
        "title": "subscriber.unsubscribed context",
        "description": "What a condition may ask about under an automation triggered by `subscriber.unsubscribed`.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "subscriber.unsubscribed"
          },
          "event": {
            "type": "array",
            "description": "The properties `subscriber.unsubscribed` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_send_id",
                "email",
                "reason",
                "subscriber_id",
                "unsubscribed_at"
              ]
            }
          },
          "subscriber": {
            "$ref": "#/components/schemas/AutomationConditionSubscriberPaths"
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextThreadCreated": {
        "type": "object",
        "title": "thread.created context",
        "description": "What a condition may ask about under an automation triggered by `thread.created`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "thread.created"
          },
          "event": {
            "type": "array",
            "description": "The properties `thread.created` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "author",
                "content",
                "created_at",
                "short_id",
                "thread_id",
                "url",
                "visibility"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `thread.created` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "AutomationContextThreadPublished": {
        "type": "object",
        "title": "thread.published context",
        "description": "What a condition may ask about under an automation triggered by `thread.published`. These events are not about a subscriber, so an automation on this trigger can hold conditions and waits but cannot send an email or move a tag.",
        "additionalProperties": false,
        "required": [
          "trigger",
          "event",
          "subscriber",
          "steps"
        ],
        "properties": {
          "trigger": {
            "type": "string",
            "const": "thread.published"
          },
          "event": {
            "type": "array",
            "description": "The properties `thread.published` carries in its payload.",
            "items": {
              "type": "string",
              "enum": [
                "article_id",
                "author",
                "previous_visibility",
                "published_at",
                "published_by",
                "short_id",
                "source",
                "thread_id",
                "url",
                "visibility"
              ]
            }
          },
          "subscriber": {
            "type": "array",
            "maxItems": 0,
            "description": "Empty: `thread.published` is not about a subscriber, so there is nobody for a condition to ask about and no subscriber-requiring action this trigger can take.",
            "items": {
              "type": "string"
            }
          },
          "steps": {
            "$ref": "#/components/schemas/AutomationConditionStepPaths"
          }
        }
      },
      "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, accepted an invitation, or was added through the API. An import does not publish this topic at all, so the only way a non-null value reaches it is a row that was imported once and has since come back: `resubscribed` is `true` whenever it does. `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",
              "removed"
            ],
            "description": "`self_service` is the reader opting out. `bounced` and `complained` are forced by delivery telemetry and arrive with the matching `delivery.*` event. `removed` is the only one the reader did not cause: the newsletter took them off its list, by setting `status` through Update a subscriber (`PATCH /subscribers/{subscriber}`) or by Erase a subscriber (`DELETE /subscribers/{subscriber}`). `actor` names the credential on those two, and on a deletion the subscriber no longer resolves, so a `404` from `GET /subscribers/{subscriber}` is how the two are told apart."
          },
          "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"
            ],
            "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. Those are the only two values."
          },
          "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",
              null
            ],
            "description": "What the thread was before. `subscribers` is the only value a thread can arrive at `public` from. 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"
          }
        }
      }
    }
  }
}
