{"openapi":"3.1.0","info":{"title":"Commune API","version":"2026-08-26","summary":"Read access to newsletters, articles, threads and the people around them.","description":"The Commune API exposes the public reading surface of a Commune community:\nnewsletters, the articles they publish, the chat threads those articles\nstart, and the people who write and read them.\n\n## Versioning\n\nThe base URL carries no version segment. A request selects a contract\nversion with the `Commune-Version` header, whose value is the release date\nof the contract (for example `2026-08-26`). Omitting the header pins the\nrequest to the version that was current when the API key was issued.\n\nEvery response echoes the version it resolved to in a `Commune-Version`\nresponse header, on success and on failure alike. A client that never sets\nthe header can read which contract it has been getting, and compare it\nagainst `version` in `GET /status` to find out whether a newer one is\navailable to move to.\n\nEvery response also carries a `Commune-Request-Id`, which is the value that\nappears as `request_id` in an error body. Quote it in support requests.\n\n## Authentication\n\nEvery request is authenticated with an API key sent as a bearer token. A key\ncarries either public scope (it can read only what is already readable by an\nanonymous visitor on the web) or creator scope (it additionally reads the\nprivate surface of the newsletters it is bound to). An operation documented\nas creator scope answers `403` to a public key.\n\n## Rate limits\n\nEvery request is counted against the key that made it, never against an\naddress. Two budgets apply. The general budget counts every request. A\nsecond, tighter budget counts only the operations that return subscriber or\nrecipient email addresses, because a key that can read those can export a\nnewsletter's audience; those operations are charged to both and have to pass\nboth.\n\nFrom the moment a key resolves, every response carries `RateLimit-Limit`,\n`RateLimit-Remaining` and `RateLimit-Reset` for whichever budget is closest\nto exhaustion, and `RateLimit-Policy` listing every budget that applied.\n`RateLimit-Reset` is in seconds from now. A `429` additionally carries\n`Retry-After`, and its `message` names the budget that refused, since being\nrefused by the audience budget still leaves the rest of the API callable.\n\n`GET /rate-limit` reports every budget at once, which is what a client\nshould read rather than inferring the whole picture from the one budget the\nheaders can describe.\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 are addressable in the product carry both a UUID `id` and a\nshort, URL friendly `short_id`. Either value is accepted wherever a path\nparameter names that resource.\n","contact":{"name":"Commune","url":"https://usecommune.com","email":"support@usecommune.com"},"termsOfService":"https://usecommune.com/terms"},"servers":[{"url":"https://api.usecommune.com","description":"Production. There is no separate sandbox host.\n"}],"tags":[{"name":"Newsletters","description":"A newsletter is the top level object in Commune. It owns its articles, its\nchat, its subscribers and its team. Everything else in this catalog 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"},{"name":"Users","description":"A person with a Commune account: the readers who join a community and the\nwriters who are credited on an article. Only the public profile is\nreadable, never an email address.\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 issue of a newsletter: written natively in Commune and\nsent, or imported from the newsletter's provider. Two rules gate every\narticle read and are described on each operation. First, an article\nstamped with an audience is visible only to the newsletter's team and to\nsubscribers holding one of its tags. Second, an article dated in the\nfuture is invisible until that moment passes.\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. Creator scope throughout, and never a public\nsurface: a newsletter's list belongs to its creator.\n"},{"name":"Subscriber tags","description":"A tag segments a newsletter's audience. Sending an issue to a tag stamps\nthat issue 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"},{"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. Creator scope, 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 issue: headline stats\nfor a period, acquisition attribution, bucketed series for charting, and\none issue'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":"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. Creator scope.\n"},{"name":"Website domains","description":"A creator's own domain pointed at their Commune site, so their community\nlives at their address rather than at ours. The same prove you own this\nhostname flow as Senders, pointed at the site rather than at the mail.\nCreator scope.\n"},{"name":"Platform","description":"The API's own machinery rather than any newsletter's data. Today that is\nthe readiness probe; key management, the rate limit view and the\nmachine readable contract documents join it as they land.\n"},{"name":"Webhooks","description":"The events Commune pushes to a consumer, rather than the resources a\nconsumer pulls. Commune publishes state changes on 21 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, `actor` and `idempotency_key`, are declared and are\nalways `null` today. They are part of the contract from the start so that\npopulating them later is an additive change rather than a new envelope\nversion. See \"Reserved nulls\" on the envelope schema.\n\nRegistration and the signature algorithm itself are not expressible as a\nPath Item and are documented in prose instead.\n"}],"security":[{"apiKey":[]}],"paths":{"/status":{"get":{"operationId":"getStatus","summary":"Service status","description":"Whether the API is serving, what it depends on to serve, and which\ncontract version it is currently on. Reaching this operation at all\nproves the process is up and routing; `status` and `dependencies` say\nwhether it is up in a useful sense.\n\nEvery check is shallow. It proves that a dependency answers, not that\nit answers 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 deliberately coarse. Because this operation is\nunauthenticated, it reports capability names and states and nothing\nelse: no vendor, no free text, and no distinction between a dependency\nthat failed its probe and one this deployment cannot reach at all.\n\nUnauthenticated, and the one operation that ignores `Commune-Version`,\nsince it describes the service rather than a resource. Because it takes\nno credential there is no key to count against, so the per-key budgets\ndescribed under Rate limits do not apply to it and it carries no\n`RateLimit-*` headers. It can still be refused by whatever sits in front\nof this service, which is why the `429` stays declared.\n","tags":["Platform"],"security":[],"responses":{"200":{"description":"The service is serving traffic. Read `status` before trusting it to\nserve every operation: a `200` here reports a degradation rather\nthan hiding it.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceStatus"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/status\n"}]}},"/rate-limit":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"}],"get":{"operationId":"getRateLimit","summary":"Retrieve the calling key's rate limit state","description":"What this key has left, on every budget that applies to it, measured\nafter this request has been counted.\n\nTwo budgets exist. `general` counts every request made with the key.\n`audience` counts only the operations that return subscriber or\nrecipient email addresses, and is smaller: those operations are charged\nto both budgets and have to pass both, so a key that has exhausted\n`audience` can still call everything else.\n\nRead this rather than inferring the whole picture from the `RateLimit-*`\nheaders. Those describe one budget, whichever is closest to exhaustion,\nbecause a retry loop can only act on one number.\n\nThis operation is itself counted against the `general` budget. An\nendpoint exempt from the limit would be the one call a client could make\nin a loop for free.\n","tags":["Platform"],"responses":{"200":{"description":"The current window, limit and remaining for this key.","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 the key can see. A public key sees the newsletters that\nhave a public profile page on the web. A creator key sees the\nnewsletters it is bound to, whether or not they are public.\n\nOrdered by `created_at` descending.\n","tags":["Newsletters"],"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. A handle is unique\nacross Commune, so this answers zero or one item. Prefer\n`GET /newsletters/{newsletter}` when you want a single object and a\n`404` rather than an empty page.\n","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of newsletters.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ListEnvelope"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Newsletter"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/newsletters?limit=20\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/newsletters/{newsletter}":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/NewsletterPath"}],"get":{"operationId":"getNewsletter","summary":"Retrieve a newsletter","description":"Read one newsletter by `id` or by `handle`. A newsletter has no\n`short_id`: its handle is the short, human readable identifier, and it\nis what the web profile at `/n/{handle}` resolves on.\n","tags":["Newsletters"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"The newsletter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Newsletter"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/newsletters/the-weekly \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/newsletters/{newsletter}/articles":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/NewsletterPath"}],"get":{"operationId":"listNewsletterArticles","summary":"List a newsletter's articles","description":"The newsletter's articles, most recently published first, ordered by\n`posted_at` descending with articles that never got a date last.\n\nTwo gates apply and neither can be turned off. An article stamped with\nan audience is returned only to a key that may read that audience: 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 issue never\nleaks early through this collection.\n\n`content` is never included here, whatever `?fields=` asks for. An issue\nbody is large enough that returning a page of them is the wrong default,\nso read it from `GET /articles/{article}`.\n","tags":["Articles"],"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 public key may only ask for\n`sent`, since a draft or a failed send is not public. Repeat the\nparameter to accept several.\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 natively 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`.\nCreator scope, because the audience of an issue 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","items":{"$ref":"#/components/schemas/Article"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/newsletters/the-weekly/articles?status=sent\u0026limit=10\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/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 key that does not hold that audience, 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 a natively published issue, `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 issue 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 issue, and ask for\n`?fields=content_markdown` with it to leave the HTML behind entirely.\n","tags":["Articles"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"The article, with `content`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArticleWithContent"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/articles/k7Rm2xQp \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/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.\nAn article usually has one author, but an imported issue can be\nattributed to several team members through the provider's creator field,\nand a native issue can be co-signed.\n\nAn article whose author was never mapped to a Commune account returns an\nempty page rather than a placeholder person.\n","tags":["Articles"],"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","items":{"$ref":"#/components/schemas/User"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/articles/k7Rm2xQp/authors \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/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.\nCreator scope.\n\nA newsletter that Commune sends natively 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"],"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","items":{"$ref":"#/components/schemas/Sender"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/newsletters/the-weekly/senders \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/senders/{sender}":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/SenderPath"}],"get":{"operationId":"getSender","summary":"Retrieve a sending address","description":"Read one sending address, including the DNS records the creator has to\npublish and the reason verification last failed. Creator scope.\n","tags":["Senders"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"The sending address.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sender"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/senders/2f8b0c1a-5d34-4e7b-8f21-6a9c4b3d2e10 \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/newsletters/{newsletter}/domains":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/NewsletterPath"}],"get":{"operationId":"listNewsletterDomains","summary":"List a newsletter's website domains","description":"The creator's own domains pointed at this newsletter's Commune site.\nCreator scope.\n\nTwo independent things have to be true for one to work, and they are\nreported separately. `verification_status` says whether ownership was\nproved and a certificate issued. `routing_ok` says whether the domain\nactually resolves to Commune, which a certificate says nothing about.\n","tags":["Website domains"],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"A page of website domains.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ListEnvelope"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Domain"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/newsletters/the-weekly/domains \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/domains/{domain}":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/DomainPath"}],"get":{"operationId":"getDomain","summary":"Retrieve a website domain","description":"Read one website domain, including the records to publish and the last\nreachability probe. Creator scope.\n","tags":["Website domains"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"The website domain.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/domains/blog.example.org \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/search":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"}],"get":{"operationId":"search","summary":"Search Commune","description":"Full text search across newsletters, articles, people and chat, ranked\nby relevance.\n\nResults obey the same gates as the resource operations do. An article\nstamped with an audience, an article dated in the future, and a thread\nthat is not public are not returned to a key that could not read them\ndirectly, so search cannot be used to see around a permission.\n\nA query shorter than two characters returns an empty page rather than an\nerror, so a caller can send it on every keystroke.\n","tags":["Search"],"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","items":{"$ref":"#/components/schemas/SearchResult"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/search?q=onboarding\u0026type=article\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/articles/{article}/highlights":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/ArticlePath"}],"get":{"operationId":"listArticleHighlights","summary":"List an article's highlights","description":"The passages readers marked in this article, in the order they appear in\nthe body. Use `start_offset` and `end_offset` to place them, and `quote`\nwith `prefix` and `suffix` to re anchor when the body has since changed.\n\nHighlights are not attributed to a named person. Each carries an\n`owner_key`, an opaque value that is stable per highlighter within one\narticle and meaningless across articles, so a client can count distinct\nhighlighters and group one person's marks without learning who they are.\n","tags":["Highlights"],"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","items":{"$ref":"#/components/schemas/Highlight"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/articles/k7Rm2xQp/highlights \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/highlights/{highlight}":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/HighlightPath"}],"get":{"operationId":"getHighlight","summary":"Retrieve a highlight","description":"Read one highlight. It answers `404` whenever its article would, so a\nhighlight cannot be used to read a passage of an issue the key is not\nentitled to.\n","tags":["Highlights"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"The highlight.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Highlight"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/highlights/7c1d9e4b-2a86-4f30-9b57-1e8d6c5a4f32 \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/newsletters/{newsletter}/tags":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/NewsletterPath"}],"get":{"operationId":"listNewsletterTags","summary":"List a newsletter's tags","description":"The newsletter's live audience tags, alphabetical, each with a count of\ncurrently subscribed holders. Creator scope: how a creator segments\ntheir audience is not public.\n\nRetired tags are excluded by default. A tag that an already sent issue\nwas addressed to cannot be deleted outright, because the audience of a\nsent issue 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 issue.\n","tags":["Subscriber tags"],"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\nissues 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","items":{"$ref":"#/components/schemas/Tag"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/newsletters/the-weekly/tags?include_retired=true\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/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. Creator scope, and only for a\nnewsletter the key is bound to.\n","tags":["Subscriber tags"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"The tag.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Tag"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/tags/4e2a7f19-8c53-4d6b-a710-3f9b2c8d5e64 \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/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\nCreator scope. Pending invitations are not members and are not returned\nhere.\n","tags":["Team"],"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","items":{"$ref":"#/components/schemas/Member"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/newsletters/the-weekly/members?role=editor\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/newsletters/{newsletter}/subscribers":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/NewsletterPath"}],"get":{"operationId":"listNewsletterSubscribers","summary":"List a newsletter's subscribers","description":"The newsletter's subscribers, most recent first. Creator scope, and\nreturns email addresses, so a key that can call this holds the\nnewsletter's list.\n\nThis is Commune's own record of the audience. For a newsletter that\nCommune sends natively 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"],"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":"`commune` returns only the people who joined through Commune,\n`imported` only those that came from the newsletter's provider or a\nfile. Omit for both.\n","schema":{"type":"string","enum":["commune","imported"]}}],"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","items":{"$ref":"#/components/schemas/Subscriber"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/newsletters/the-weekly/subscribers?status=subscribed\u0026limit=100\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/subscribers/{subscriber}":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/SubscriberPath"}],"get":{"operationId":"getSubscriber","summary":"Retrieve a subscriber","description":"Read one subscriber by `id`. Creator scope, and only within a newsletter\nthe key is bound to: the same person subscribing to two newsletters is\ntwo subscribers, and one creator never sees the other's row.\n","tags":["Subscribers"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"The subscriber.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Subscriber"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/subscribers/b81f3d27-6e40-4a95-8c13-5d7e2f0a9b64 \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/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 public key sees only threads whose `visibility` is `public`. A creator\nkey additionally sees `subscribers` and `paid` threads, which are the\nones a reader has to have joined the newsletter to read.\n\nThreads opened by an article are included and carry `is_article_thread`\nas `true`. Filter them out with `is_article_thread=false` when you want\nonly what people started themselves.\n","tags":["Threads"],"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 public key answers `403`. Repeat the parameter\nto 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","items":{"$ref":"#/components/schemas/Thread"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/newsletters/the-weekly/threads?visibility=public\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/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 key, neither is its thread.\n","tags":["Threads"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"The thread.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/threads/b3Xn8kTw \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/threads/{thread}/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"],"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","items":{"$ref":"#/components/schemas/Message"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/threads/b3Xn8kTw/messages \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/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"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"The message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/messages/p9Wd4vHs \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/messages/{message}/reactions":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/MessagePath"}],"get":{"operationId":"listMessageReactions","summary":"List a message's reactions","description":"The emoji reactions on a message, one entry per distinct emoji with the\npeople who left it. Ordered by `count` descending, then by the emoji\nitself so the order is stable between two reads that tie.\n","tags":["Messages"],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"A page of reactions, most used first.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ListEnvelope"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Reaction"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/messages/p9Wd4vHs/reactions \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/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. Email addresses, notification\nsettings, saved articles and read state are never returned here: they\nbelong to the person, not to a key.\n","tags":["Users"],"parameters":[{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"The user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/users/ada \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/users/{user}/newsletters":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/UserPath"}],"get":{"operationId":"listUserNewsletters","summary":"List the newsletters a user publishes","description":"The newsletters this person owns or is a team member of, filtered to\nthose the key may see. It answers \"what does this writer publish\", not\n\"what does this reader follow\", which is\n`GET /users/{user}/subscriptions`.\n","tags":["Users"],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"A page of newsletters.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ListEnvelope"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Newsletter"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/users/ada/newsletters \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/users/{user}/subscriptions":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/UserPath"}],"get":{"operationId":"listUserSubscriptions","summary":"List the newsletters a user reads","description":"The newsletters this person subscribes to. Creator scope, and only for\nthe newsletters the key is bound to: a reader's full reading list across\nCommune is theirs, so a key never sees subscriptions to newsletters it\ndoes not own.\n","tags":["Users"],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"A page of newsletters.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ListEnvelope"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Newsletter"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/users/ada/subscriptions \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/newsletters/{newsletter}/insights":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/NewsletterPath"}],"get":{"operationId":"listNewsletterInsights","summary":"List a newsletter's subscriber insights","description":"Per subscriber engagement scoring. Each row blends what a reader did in\nthe newsletter's community with what its email provider reported about\nthem, carries a fourteen day over fourteen day velocity, and lands on a\nlifecycle status. It is the object a CRM or a re engagement automation\nreads, and the one thing in this catalog no email provider can answer,\nbecause half the signal never reaches it.\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\nOnly people with a Commune account are scored, because engagement is\nattributed to an account. Someone the newsletter knows only as an email\naddress has no row here and is absent from this collection rather than\npresent with a zero score, so the size of this collection is not the\nsize of the audience.\n\n`status` is assigned by rank within the newsletter, not by an absolute\nscore, so it moves when the people around a reader move even if that\nreader did nothing.\n\nOrdered by `total_score` descending, so the first page is the\nnewsletter's most engaged readers. Creator scope, and the one read\nCommune may put behind a plan: a key without it answers `402`.\n","tags":["Engagement"],"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","items":{"$ref":"#/components/schemas/SubscriberInsight"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/newsletters/the-weekly/insights?status=superfan\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/newsletters/{newsletter}/events":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/NewsletterPath"}],"get":{"operationId":"listNewsletterEvents","summary":"List a newsletter's engagement events","description":"The raw engagement stream the insight scores are built from, with both\norigins unified into one vocabulary: what a reader did inside the\ncommunity, and what the newsletter's email provider reported about the\nsame person. `source` says which side an event came from.\n\nThese are engagement records, not the events Commune pushes to a\nconsumer. They describe reader behaviour and are read on request. What\nCommune pushes is the `webhooks` block of this document.\n\nOrdered by `id` descending, and the cursor walks the same key, so a\nwarehouse can tail this collection and be sure that nothing inserted\nmid page is skipped.\n\nEvery event is attributed to a Commune account, so a subscriber the\nnewsletter knows only as an email address never appears here even when\nthe provider reported an open for that address.\n\nCreator scope, and gated with the rest of this domain: a key whose plan\ndoes not include it answers `402`.\n","tags":["Engagement"],"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","items":{"$ref":"#/components/schemas/EngagementEvent"}}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/newsletters/the-weekly/events?source=esp\u0026limit=100\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/newsletters/{newsletter}/stats":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/NewsletterPath"}],"get":{"operationId":"getNewsletterStats","summary":"Retrieve a newsletter's headline numbers","description":"One snapshot of a newsletter over a window: how the audience moved, what\nwas published, how much the community talked, and how the email\nperformed. It answers the question a dashboard opens with, in one call\nrather than six.\n\nEvery number is scoped to the window. Pick the window with `period`, or\nstate it exactly with `since` and `until`.\n\nRead `audience` and `publishing` carefully before charting them.\nCommune's record of a newsletter's subscribers is complete only for a\nnewsletter Commune sends natively. 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 issues Commune has a\nrecord of and is never a count of emails delivered, which lives in\n`delivery`.\n","tags":["Metrics"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"},{"$ref":"#/components/parameters/StatsPeriod"},{"$ref":"#/components/parameters/StatsSince"},{"$ref":"#/components/parameters/StatsUntil"}],"responses":{"200":{"description":"The newsletter's numbers for the resolved window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsletterStats"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/newsletters/the-weekly/stats?period=90d\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/newsletters/{newsletter}/growth":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/NewsletterPath"}],"get":{"operationId":"getNewsletterGrowth","summary":"Retrieve a newsletter's acquisition breakdown","description":"Where a newsletter's new subscribers came from over a window: the split\nby acquisition source, how many of them arrived through Commune itself,\nand how the invite funnel performed.\n\nA subscriber's source is frozen when the row is first written, so\nmigrating a newsletter between providers later never relabels the\nhistory.\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\n`invites` describes Commune's own invitations to the people on the list\nwho have no Commune account yet. Opting out of those is deliberately\nseparate from unsubscribing from the newsletter, so `opted_out` here\nsays nothing about whether those people still receive the email.\n","tags":["Metrics"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"},{"$ref":"#/components/parameters/StatsPeriod"},{"$ref":"#/components/parameters/StatsSince"},{"$ref":"#/components/parameters/StatsUntil"}],"responses":{"200":{"description":"The acquisition breakdown for the resolved window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsletterGrowth"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/newsletters/the-weekly/growth?period=90d\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/newsletters/{newsletter}/timeseries":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/NewsletterPath"}],"get":{"operationId":"getNewsletterTimeseries","summary":"Retrieve one newsletter metric bucketed over time","description":"One metric, bucketed, for charting. Every other operation in this domain\nanswers with a snapshot, which is the wrong shape for a line, so this\none returns the series behind it.\n\nName the quantity with `metric` and the bucket width with `interval`.\nBuckets are contiguous, and a bucket in which nothing happened comes\nback with a `value` of `0` rather than being omitted, so a client can\nplot the array without filling the gaps itself.\n\n`subscribers` counts subscriptions Commune recorded in each bucket, not\nthe running audience size, and it inherits the partial cache caveat that\napplies wherever Commune counts the subscribers of a newsletter\nconnected to an outside provider. `sends` counts the dispatches Commune\nperformed and is never a count of every email the newsletter has sent,\nbecause an imported issue was mailed by the provider before Commune saw\nit.\n","tags":["Metrics"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"},{"$ref":"#/components/parameters/StatsSince"},{"$ref":"#/components/parameters/StatsUntil"},{"name":"metric","in":"query","required":true,"description":"Which quantity to bucket. Exactly one per request.","schema":{"$ref":"#/components/schemas/TimeseriesMetric"}},{"name":"interval","in":"query","required":false,"description":"How wide each bucket is. Defaults to `day`. A week starts on Monday\nand a month on the first, both in UTC.\n","schema":{"$ref":"#/components/schemas/TimeseriesInterval"}}],"responses":{"200":{"description":"The requested metric, bucketed over the resolved window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Timeseries"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl \"https://api.usecommune.com/newsletters/the-weekly/timeseries?metric=subscribers\u0026interval=week\" \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}},"/articles/{article}/stats":{"parameters":[{"$ref":"#/components/parameters/CommuneVersion"},{"$ref":"#/components/parameters/ArticlePath"}],"get":{"operationId":"getArticleStats","summary":"Retrieve one article's performance","description":"How one issue 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 issue Commune sent itself. For an issue\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 issue 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\nCreator scope. The public tallies on an article, which are `likes`,\n`comments` and `highlights`, live on the article itself and need no\ncreator key.\n","tags":["Metrics"],"parameters":[{"$ref":"#/components/parameters/Expand"},{"$ref":"#/components/parameters/Fields"}],"responses":{"200":{"description":"The article's email and community performance.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArticlePerformance"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl https://api.usecommune.com/articles/k7Rm2xQp/stats \\\n  -H \"Authorization: Bearer $COMMUNE_API_KEY\" \\\n  -H \"Commune-Version: 2026-08-26\"\n"}]}}},"webhooks":{"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`native_send`: a Commune-hosted issue finished sending. Its status moved\nfrom `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":[{"webhookSignature":[]}],"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":{"nativeIssueSent":{"summary":"A native issue 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":"native_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.scheduled":{"post":{"operationId":"onArticleScheduled","summary":"Article scheduled","description":"A native issue was queued for a future send. Its status became\n`scheduled` and its `scheduled_for` is in the future.\n\nCancelling a schedule is a real state change too, but is not one of the\n21 topics. A consumer that needs to know a schedule went away should\nreconcile against the REST resource.\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":[{"webhookSignature":[]}],"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":"Issue 42","scheduled_for":"2026-08-27T08:00:00Z"}}}}}}},"responses":{"200":{"description":"The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"},"4XX":{"description":"The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"},"5XX":{"description":"The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"}}}},"billing.subscription.updated":{"post":{"operationId":"onBillingSubscriptionUpdated","summary":"Billing subscription state changed","description":"The newsletter's own Commune subscription moved between billing states:\ntrialing, active, past_due, canceled or trial_expired.\n\nTwo things move it. Activity at the payment provider: a checkout\ncompleting, the subscription being created, updated or deleted, an\ninvoice being paid or failing. And a daily pass that expires trials\nwhich have run out, which is why a `trial_expired` event can arrive with\nno creator action behind it.\n\nNamed `billing.subscription.*` on purpose. In Commune a \"subscriber\" is\na reader of a newsletter, so an unqualified `subscription.updated` would\nread as a reader event. This one is about the creator paying Commune.\n\nA state here gates publishing. Past due beyond the grace window,\ncanceled, and an expired trial each refuse a send with 402. A newsletter\nthat is trialing or past due is additionally capped on how many emails\nit may send in a day, and exceeding that refuses with 429 rather than\n402, so a consumer watching for payment problems should treat both codes\nas 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":[{"webhookSignature":[]}],"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. From the `email.bounced` ingest\nbranch.\n\nThis has a side effect on the subscriber: they are flipped to `bounced`\nso later sends skip them, which also emits `subscriber.unsubscribed`\nwith `reason: bounced`. Expect both events for one bounce.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n","tags":["Webhooks"],"security":[{"webhookSignature":[]}],"parameters":[{"$ref":"#/components/parameters/WebhookVersion"},{"$ref":"#/components/parameters/WebhookEventId"},{"$ref":"#/components/parameters/WebhookEventType"},{"$ref":"#/components/parameters/WebhookSignature"},{"$ref":"#/components/parameters/WebhookTimestamp"},{"$ref":"#/components/parameters/WebhookDeliveryAttempt"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryBouncedEvent"},"examples":{"bounced":{"summary":"Arrives together with subscriber.unsubscribed carrying reason bounced.","value":{"id":"018f2a90-7777-7000-8000-000000000007","type":"delivery.bounced","api_version":"2026-08-26","occurred_at":"2026-08-26T09:34:02Z","newsletter_id":"7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411","actor":null,"idempotency_key":null,"data":{"delivery_id":"1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9","article_id":"4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f","send_id":"9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d","subscriber_id":"33445566-7788-4990-a1b2-c3d4e5f60718","email":"gone@example.com","provider_message_id":"4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4","status":"bounced","reason":"The recipient address does not exist."}}}}}}},"responses":{"200":{"description":"The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"},"4XX":{"description":"The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"},"5XX":{"description":"The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"}}}},"delivery.clicked":{"post":{"operationId":"onDeliveryClicked","summary":"Delivery link clicked","description":"The recipient clicked a tracked link. From the `email.clicked` ingest\nbranch.\n\nWhich link was clicked is deliberately absent from the payload. The\ningest records only that a click happened and when the first one did, so\nCommune holds no link-level detail and this contract will not imply that\nit does.\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":[{"webhookSignature":[]}],"parameters":[{"$ref":"#/components/parameters/WebhookVersion"},{"$ref":"#/components/parameters/WebhookEventId"},{"$ref":"#/components/parameters/WebhookEventType"},{"$ref":"#/components/parameters/WebhookSignature"},{"$ref":"#/components/parameters/WebhookTimestamp"},{"$ref":"#/components/parameters/WebhookDeliveryAttempt"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryClickedEvent"},"examples":{"clicked":{"value":{"id":"018f2a90-6666-7000-8000-000000000006","type":"delivery.clicked","api_version":"2026-08-26","occurred_at":"2026-08-26T10:12:30Z","newsletter_id":"7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411","actor":null,"idempotency_key":null,"data":{"delivery_id":"1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9","article_id":"4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f","send_id":"9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d","subscriber_id":"33445566-7788-4990-a1b2-c3d4e5f60718","email":"reader@example.com","provider_message_id":"4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4","status":"clicked"}}}}}}},"responses":{"200":{"description":"The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"},"4XX":{"description":"The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"},"5XX":{"description":"The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"}}}},"delivery.complained":{"post":{"operationId":"onDeliveryComplained","summary":"Delivery marked as spam","description":"The recipient reported the message as spam. From the `email.complained`\ningest branch.\n\nTreated as more severe than a bounce and never overridden: the\nsubscriber is flipped to `complained` and must not be mailed again,\nwhich also emits `subscriber.unsubscribed` with `reason: complained`.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n","tags":["Webhooks"],"security":[{"webhookSignature":[]}],"parameters":[{"$ref":"#/components/parameters/WebhookVersion"},{"$ref":"#/components/parameters/WebhookEventId"},{"$ref":"#/components/parameters/WebhookEventType"},{"$ref":"#/components/parameters/WebhookSignature"},{"$ref":"#/components/parameters/WebhookTimestamp"},{"$ref":"#/components/parameters/WebhookDeliveryAttempt"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryComplainedEvent"},"examples":{"complained":{"value":{"id":"018f2a90-8888-7000-8000-000000000008","type":"delivery.complained","api_version":"2026-08-26","occurred_at":"2026-08-26T11:02:19Z","newsletter_id":"7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411","actor":null,"idempotency_key":null,"data":{"delivery_id":"1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9","article_id":"4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f","send_id":"9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d","subscriber_id":"33445566-7788-4990-a1b2-c3d4e5f60718","email":"annoyed@example.com","provider_message_id":"4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4","status":"complained","reason":"Spam complaint"}}}}}}},"responses":{"200":{"description":"The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"},"4XX":{"description":"The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"},"5XX":{"description":"The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"}}}},"delivery.delivered":{"post":{"operationId":"onDeliveryDelivered","summary":"Delivery accepted by the recipient server","description":"The provider confirmed the message reached the recipient's mail server.\nFrom the `email.delivered` ingest branch.\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":[{"webhookSignature":[]}],"parameters":[{"$ref":"#/components/parameters/WebhookVersion"},{"$ref":"#/components/parameters/WebhookEventId"},{"$ref":"#/components/parameters/WebhookEventType"},{"$ref":"#/components/parameters/WebhookSignature"},{"$ref":"#/components/parameters/WebhookTimestamp"},{"$ref":"#/components/parameters/WebhookDeliveryAttempt"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryDeliveredEvent"},"examples":{"delivered":{"value":{"id":"018f2a90-4444-7000-8000-000000000004","type":"delivery.delivered","api_version":"2026-08-26","occurred_at":"2026-08-26T09:32:48Z","newsletter_id":"7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411","actor":null,"idempotency_key":null,"data":{"delivery_id":"1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9","article_id":"4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f","send_id":"9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d","subscriber_id":"33445566-7788-4990-a1b2-c3d4e5f60718","email":"reader@example.com","provider_message_id":"4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4","status":"delivered"}}}}}}},"responses":{"200":{"description":"The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"},"4XX":{"description":"The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"},"5XX":{"description":"The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"}}}},"delivery.opened":{"post":{"operationId":"onDeliveryOpened","summary":"Delivery opened","description":"The recipient opened the message. From the `email.opened` ingest branch.\n\nFires on every reported open, but `opened_at` on the delivery row is\nstamped only on the first one, so the row keeps first-open semantics\nwhile the topic keeps every occurrence. Open tracking is a pixel and is\nunreliable by nature: privacy proxies inflate it and image-blocking\nclients suppress it.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n","tags":["Webhooks"],"security":[{"webhookSignature":[]}],"parameters":[{"$ref":"#/components/parameters/WebhookVersion"},{"$ref":"#/components/parameters/WebhookEventId"},{"$ref":"#/components/parameters/WebhookEventType"},{"$ref":"#/components/parameters/WebhookSignature"},{"$ref":"#/components/parameters/WebhookTimestamp"},{"$ref":"#/components/parameters/WebhookDeliveryAttempt"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryOpenedEvent"},"examples":{"opened":{"value":{"id":"018f2a90-5555-7000-8000-000000000005","type":"delivery.opened","api_version":"2026-08-26","occurred_at":"2026-08-26T10:11:04Z","newsletter_id":"7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411","actor":null,"idempotency_key":null,"data":{"delivery_id":"1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9","article_id":"4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f","send_id":"9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d","subscriber_id":"33445566-7788-4990-a1b2-c3d4e5f60718","email":"reader@example.com","provider_message_id":"4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4","status":"opened"}}}}}}},"responses":{"200":{"description":"The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"},"4XX":{"description":"The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"},"5XX":{"description":"The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"}}}},"domain.verified":{"post":{"operationId":"onDomainVerified","summary":"Custom website domain verified","description":"A creator's custom website domain went live: its `verification_status`\nbecame `active`. That state means ownership was validated, the\ncertificate was issued, and the hostname was confirmed to actually reach\nCommune. All three, so this is the point at which the domain serves the\nsite rather than merely resolving.\n\nThis is the rendering domain (a creator's own hostname serving their\nCommune site), not the email sending domain. That one is\n`sender.verified`.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n","tags":["Webhooks"],"security":[{"webhookSignature":[]}],"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\nPersonal by design, and the payload keeps it that way. A highlight is\nnever attributed to a named reader anywhere in Commune, so this event\ncarries the same opaque `owner_key` the REST resource does and no\n`user_id`. It is the one topic whose payload cannot be resolved back to\na person.\n\nFires on a genuine insert only. The write is an upsert keyed on (user,\narticle, start_offset, end_offset), so re-marking the same span is\nidempotent and silent. Attaching a chat message to an existing\nhighlight, which is how a passage becomes a discussion, updates that row\nrather than inserting one, so it does not fire here either. What it does\nfire is `message.created` for the message itself.\n\nA highlight created together with its discussion carries\n`data.message_id` from the start. One created silently carries null, and\nnever gains a value on this topic.\n\nArticle visibility is enforced before the write, so a passage of a\ntag-scoped article can only be marked by someone in its audience.\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":[{"webhookSignature":[]}],"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 was flipped onto native sending. That is the\ndestructive step of the move off a provider, and after it Commune sends\nthe newsletter itself.\n\nWhat this topic is for is invalidation. Every one of these runs writes\nmany rows at once, and none of them fans out into per-row events: an\nimport does not emit one `article.published` or one `subscriber.created`\nper row it touched, because a provider migration of fifty thousand\ncontacts would bury a consumer. This is the single event that says the\nshape of the newsletter just changed underneath you, so refetch.\n\nOnly a run that reached its end publishes here. Both import paths stream\ntheir progress and swallow per-row failures, so a partial run still\nfinishes and still fires, with a lower `imported_count` and no separate\ncount of what it dropped. A run whose connection died mid-stream\nproduces nothing at all, and there is no `import.failed` to pair with\nthis.\n\nThere is no run identifier on this payload and no `/imports/{id}` to\nlook one up in. An import is a streamed request that ends by writing its\nrows and closing the response: nothing persists a job row, so an id here\nwould reference something no consumer could ever fetch. What identifies\na run is the newsletter, the kind and the source, and the only thing to\ndo with one is refetch.\n\nEvery one of these is idempotent and safe to re-run, so a consumer\nshould expect to see the same import kind more than once for one\nnewsletter.\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":[{"webhookSignature":[]}],"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 back issues 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 now sends natively. 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":[{"webhookSignature":[]}],"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, not for the article. A send run is its own resource\nin the REST catalog (`/sends`), it carries its own id, and one article\ncan have several across retries, so the topic sits in the send namespace\nalongside `send.failed` rather than under `article.*`. `data.article_id`\nis still 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":[{"webhookSignature":[]}],"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":[{"webhookSignature":[]}],"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 21 topics.\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":[{"webhookSignature":[]}],"parameters":[{"$ref":"#/components/parameters/WebhookVersion"},{"$ref":"#/components/parameters/WebhookEventId"},{"$ref":"#/components/parameters/WebhookEventType"},{"$ref":"#/components/parameters/WebhookSignature"},{"$ref":"#/components/parameters/WebhookTimestamp"},{"$ref":"#/components/parameters/WebhookDeliveryAttempt"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SenderVerifiedEvent"},"examples":{"customDomainVerified":{"value":{"id":"018f2a91-ffff-7000-8000-00000000000f","type":"sender.verified","api_version":"2026-08-26","occurred_at":"2026-08-26T16:00:12Z","newsletter_id":"7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411","actor":null,"idempotency_key":null,"data":{"sender_id":"d3e4f506-1728-4939-a4b5-c6d7e8f90a1b","from_email":"hello@mail.example.com","from_name":"The Example Letter","reply_to_email":null,"domain":"mail.example.com","kind":"custom","is_default":true,"verified_at":"2026-08-26T16:00:12Z"}}}}}}},"responses":{"200":{"description":"The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"},"4XX":{"description":"The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"},"5XX":{"description":"The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"}}}},"subscriber.created":{"post":{"operationId":"onSubscriberCreated","summary":"Subscriber created","description":"Someone became a subscriber of the newsletter. Four paths land here:\nsubscribing from inside the Commune app, finishing signup, accepting an\ninvitation, and a CSV or ESP import.\n\nOnly the import path records where they came from.\n`data.acquisition_source` carries the provider on that path and is null\non the other three, so a null is the normal case for anyone who arrived\nthrough Commune itself rather than a sign that the origin was lost.\n\nReactivation counts. Unsubscribing is a soft flip, not a delete, so a\nreturning reader updates the existing row back to `subscribed` rather\nthan inserting a new one. That still fires this topic, with\n`data.resubscribed: true`, because from a consumer's point of view an\ninactive contact just became active again.\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":[{"webhookSignature":[]}],"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 topic\nthat makes Insights actionable. The scores themselves are a number a\ndashboard draws; a crossing is the moment a CRM or a re-engagement\nautomation has something to do.\n\nFired by a scheduled scoring pass that recomputes the insight record of\nevery scored reader. One event per reader whose `status` actually\ndiffers from the one already stored, so a pass that changes only the\nscores is silent.\n\nBecause the pass is scheduled, `occurred_at` is when the crossing was\ncomputed and not when the reader acted. `data.last_action_at` is the\nreader's own clock and sits earlier than it by up to a full scoring\ninterval. A consumer that wants to react while the moment is still warm\nshould read that field rather than `occurred_at`.\n\nStatus is a rank inside this newsletter, not an absolute score, so a\nreader can be moved by other readers. Someone who did nothing at all can\nstill be demoted because the audience around them got busier. Two events\nthat look like opposite movements can therefore arrive from one pass\nwithout either reader having changed their behaviour.\n\nOnly readers with a Commune account are scored, since engagement is\nattributed to an account, so a subscriber the newsletter knows only as\nan address never produces this event.\n\nNot the same thing as `subscriber.unsubscribed`. That reports the\nsubscription itself ending. This reports how engaged someone is while\nthey are still subscribed, and `dormant` is a reader who went quiet, not\na reader who left.\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":[{"webhookSignature":[]}],"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 for both directions on purpose. Membership of a segment\nchanging is a single concept, and a consumer mirroring an audience wants\nevery change to it, so splitting the directions would only make them\nsubscribe twice to hear the whole story.\n\nApplying a tag is idempotent, so re-applying one the subscriber already\nhas does not produce a second event. Tags matter beyond bookkeeping:\nthey scope who an article is sent to and who can see it, so treat these\nas segment membership changes rather than as labels.\n\nDeleting the tag itself is a soft delete and is a different, unmodelled\nchange: it retires the tag for everyone at once rather than removing one\nassignment, so it does not fan out 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":[{"webhookSignature":[]}],"parameters":[{"$ref":"#/components/parameters/WebhookVersion"},{"$ref":"#/components/parameters/WebhookEventId"},{"$ref":"#/components/parameters/WebhookEventType"},{"$ref":"#/components/parameters/WebhookSignature"},{"$ref":"#/components/parameters/WebhookTimestamp"},{"$ref":"#/components/parameters/WebhookDeliveryAttempt"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriberTaggedEvent"},"examples":{"tagAssigned":{"value":{"id":"018f2a91-bbbb-7000-8000-00000000000b","type":"subscriber.tagged","api_version":"2026-08-26","occurred_at":"2026-08-26T14:00:00Z","newsletter_id":"7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411","actor":null,"idempotency_key":null,"data":{"subscriber_id":"33445566-7788-4990-a1b2-c3d4e5f60718","email":"reader@example.com","tag_id":"aa11bb22-cc33-4d44-8e55-ff6677889900","tag_name":"Founding member","direction":"assigned"}}},"tagRemoved":{"value":{"id":"018f2a91-cccc-7000-8000-00000000000c","type":"subscriber.tagged","api_version":"2026-08-26","occurred_at":"2026-08-26T14:05:00Z","newsletter_id":"7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411","actor":null,"idempotency_key":null,"data":{"subscriber_id":"33445566-7788-4990-a1b2-c3d4e5f60718","email":"reader@example.com","tag_id":"aa11bb22-cc33-4d44-8e55-ff6677889900","tag_name":"Founding member","direction":"removed"}}}}}}},"responses":{"200":{"description":"The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"},"4XX":{"description":"The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"},"5XX":{"description":"The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"}}}},"subscriber.unsubscribed":{"post":{"operationId":"onSubscriberUnsubscribed","summary":"Subscriber unsubscribed","description":"A subscriber stopped being mailable. `data.reason` says why.\n\n`self_service`: the reader opted out themselves, either through the\nunsubscribe link in an email or the RFC 8058 one-click header, or from\ninside the Commune app.\n\n`bounced` and `complained`: forced by delivery telemetry rather than\nchosen by the reader, so they arrive alongside `delivery.bounced` or\n`delivery.complained`.\n\nThe row survives, it is only flipped, which is what preserves bounce and\ncomplaint history and lets a later resubscribe reuse 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":[{"webhookSignature":[]}],"parameters":[{"$ref":"#/components/parameters/WebhookVersion"},{"$ref":"#/components/parameters/WebhookEventId"},{"$ref":"#/components/parameters/WebhookEventType"},{"$ref":"#/components/parameters/WebhookSignature"},{"$ref":"#/components/parameters/WebhookTimestamp"},{"$ref":"#/components/parameters/WebhookDeliveryAttempt"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriberUnsubscribedEvent"},"examples":{"oneClickUnsubscribe":{"value":{"id":"018f2a91-aaaa-7000-8000-00000000000a","type":"subscriber.unsubscribed","api_version":"2026-08-26","occurred_at":"2026-08-26T13:41:52Z","newsletter_id":"7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411","actor":null,"idempotency_key":null,"data":{"subscriber_id":"33445566-7788-4990-a1b2-c3d4e5f60718","email":"reader@example.com","reason":"self_service","unsubscribed_at":"2026-08-26T13:41:52Z","article_send_id":"9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d"}}}}}}},"responses":{"200":{"description":"The consumer accepted the delivery. Any 2xx acknowledges the message\nand it is not sent again.\n"},"4XX":{"description":"The consumer rejected the delivery. Handled exactly like a 5xx: the\nmessage is retried with backoff, because a rejection cannot be told\napart from a consumer that is briefly misconfigured.\n"},"5XX":{"description":"The consumer failed to handle the delivery. Retried with backoff. A\ntimeout is the same case and is retried too.\n"}}}},"thread.created":{"post":{"operationId":"onThreadCreated","summary":"Thread created","description":"A new top-level thread was started in a newsletter's space\n(`thread_level = 0`).\n\n`data.visibility` decides where it appears: `public` puts it on the\nglobal feed and only team members may set it, `subscribers` keeps it\ninside the newsletter's own space. `paid` exists in the enum and is not\nin use yet.\n\nA thread created public fires `thread.published` from the same write, so\na consumer that only cares about the feed can subscribe to that topic\nalone and ignore this one.\n\nDelivered as a single HTTPS POST to the consumer's registered endpoint,\nwith the message as the JSON request body. Respond 2xx to acknowledge.\nAnything else, or a timeout, is retried with backoff, so acknowledge\nfast and do the work afterwards.\n","tags":["Webhooks"],"security":[{"webhookSignature":[]}],"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 the\nglobal feed. Since posts were retired into chat, this is the single\ndeliberate path by which community content leaves one newsletter's space\nand reaches everyone, so it is the topic to watch for anything that\nmirrors, syndicates or moderates the feed.\n\nTwo code paths reach `public` and both publish here, distinguished by\n`data.source`.\n\n`visibility_changed`: a team member featured a thread that already\nexisted. Who featured it and when are recorded by the same change, and\nreach this payload as `published_by` and `published_at`.\n\n`created_public`: the thread was born public, because a team member may\nset `visibility: 'public'` when starting a top-level thread. That thread\nfires `thread.created` and this topic from the same write, and has no\n`published_by`.\n\nOnly team members (owner, admin or editor) can reach `public` on either\npath, and only a top-level thread can: featuring anything with a\nnon-zero `thread_level` is refused, and replies inherit their thread's\nplacement. Going the other way, back to `subscribers`, is a real change\nbut is not modelled: a consumer mirroring the feed should reconcile\nagainst the REST resource rather than assume a thread it saw here is\nstill public.\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":[{"webhookSignature":[]}],"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":{"apiKey":{"type":"http","scheme":"bearer","bearerFormat":"Commune API key","description":"A Commune API key, sent as `Authorization: Bearer \u003ckey\u003e`. Keys are\nissued per newsletter and carry either public or creator scope.\n"},"webhookSignature":{"type":"apiKey","in":"header","name":"Commune-Signature","description":"An HMAC over the raw body of a webhook request, sent with the timestamp\nit was signed at in `Commune-Timestamp`. This is the credential Commune\npresents to a consumer, which is why every operation under `webhooks`\ndeclares it rather than inheriting the document level API key: that one\ntravels the other way. Verify against the raw bytes before parsing the\nJSON, and reject a timestamp outside your tolerance window so a\ncaptured request cannot be replayed later.\n"}},"parameters":{"CommuneVersion":{"name":"Commune-Version","in":"header","required":false,"description":"The contract version this request is written against, as a release date\n(`YYYY-MM-DD`). Omitting the header pins the request to the version that\nwas current when the API key was issued, so an integration keeps working\nwhen a newer version ships. An unknown value answers `400` with\n`invalid_version`.\n","schema":{"type":"string","format":"date","examples":["2026-08-26"]}},"NewsletterPath":{"name":"newsletter","in":"path","required":true,"description":"The newsletter's `id` (a UUID) or its `handle`. A handle is unique\nacross Commune and is the identifier its public web profile uses, so it\nis the one to hardcode in an integration.\n","schema":{"type":"string"},"examples":{"byId":{"summary":"By UUID","value":"9a4c1c6e-0f2b-4f47-9d3f-6d1b1a2c3d4e"},"byHandle":{"summary":"By handle","value":"the-weekly"}}},"ArticlePath":{"name":"article","in":"path","required":true,"description":"The article's `id` (a UUID) or its `short_id`, an eight character base62\nstring that is unique across Commune. The `slug` is not accepted here\nbecause it is unique only within a newsletter.\n","schema":{"type":"string"},"examples":{"byShortId":{"summary":"By short id","value":"k7Rm2xQp"}}},"SenderPath":{"name":"sender","in":"path","required":true,"description":"The sending address's `id`. It has no short id.","schema":{"type":"string","format":"uuid"}},"DomainPath":{"name":"domain","in":"path","required":true,"description":"The website domain's `id`, or the hostname itself. The hostname is\nunique across Commune, so either resolves.\n","schema":{"type":"string"},"examples":{"byHostname":{"summary":"By hostname","value":"blog.example.org"}}},"HighlightPath":{"name":"highlight","in":"path","required":true,"description":"The highlight's `id`. A highlight has no short id.","schema":{"type":"string","format":"uuid"}},"TagPath":{"name":"tag","in":"path","required":true,"description":"The tag's `id`. A tag has no short id.","schema":{"type":"string","format":"uuid"}},"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\nOne accepted path is not a relationship. `?expand=content` on\n`GET /articles/{article}` adds the Markdown rendition of the body\nbeside the HTML one. It is the same trade the parameter always offers,\na fuller response for a larger one, over a property that has more than\none representation rather than over a reference.\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\nA trimmed body is a subset of the schema this operation declares, and a\nproperty that schema marks required is absent when it was not asked for.\nThat is the point of the parameter, so a client that validates responses\nagainst the schema either sends no `fields` or relaxes `required`.\n","schema":{"type":"string"},"examples":{"trimmed":{"summary":"Only the fields a link list needs","value":"title,slug,posted_at"}}},"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 over the raw request body. Verify before parsing.","schema":{"type":"string"}},"WebhookTimestamp":{"name":"Commune-Timestamp","in":"header","required":true,"description":"Unix seconds the signature was produced at. Reject anything outside your tolerance window.","schema":{"type":"string"}},"WebhookDeliveryAttempt":{"name":"Commune-Delivery-Attempt","in":"header","required":false,"description":"1 on the first attempt, incremented on each retry. Informational only. A consumer must be idempotent regardless of its value.","schema":{"type":"integer","minimum":1}}},"responses":{"BadRequest":{"description":"The request was malformed: an unknown query parameter, an unparseable\ncursor, an unknown `expand` path, or an unrecognised `Commune-Version`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unauthorized":{"description":"No API key was presented, or the key is unknown, revoked or expired.\n\nAll four answer identically, down to the wording. Saying that a key was\nrevoked rather than never issued confirms to whoever is holding the\nstring that it was once real, which a legitimate caller does not need\nand a thief should not get.\n","headers":{"WWW-Authenticate":{"description":"The authentication scheme this API accepts. Always\n`Bearer realm=\"Commune API\"`; there is no second scheme and no\nquery-parameter fallback, because a credential that can travel in a\nURL ends up in access logs and referer headers.\n","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"The key is valid but is not allowed to read this. Either it carries\npublic scope and the operation needs creator scope, or it is bound to a\ndifferent newsletter than the one addressed.\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 key is allowed to read this but its plan does not include it.\nInsights is the one read surface Commune reserves the right to meter,\nbecause its numbers are computed rather than looked up: the scores and\nthe event stream behind them are the expensive half of the catalog and\nthe half no email provider can answer. Every other read stays free.\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","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"}}}}},"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. Each list operation narrows `data`\nto the item type it returns.\n","required":["object","data","pagination"],"properties":{"object":{"type":"string","const":"list","description":"Always `list`, so a response is self describing."},"data":{"type":"array","description":"This page of items, in the order the operation documents.","items":{}},"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 is deliberately redundant with `message`, which names the\nsame values in prose. The array is what a program branches on\nand the sentence is what a model reads, and a caller correcting\na request has to be able to do it from this one response\nwithout opening a reference page.\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 no parameter at\nfault and `param` is absent, and this carries the scopes that\nwould have been accepted 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 reference page for this error code.","examples":["https://usecommune.com/docs/api/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","enum":["bad_request","invalid_version","unauthorized","forbidden","insufficient_scope","payment_required","not_found","conflict","unprocessable","rate_limited","internal_error"]},"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. `getStatus` 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","email_service","payments","edge"]},"state":{"$ref":"#/components/schemas/DependencyState"},"required":{"type":"boolean","description":"Whether the API can serve at all without it. The database is\nrequired. `email_service`, `payments` and `edge` are not: they back\nsending, billing and domain provisioning, none of which the current\nread-only operations touch, so losing one narrows the surface\nrather than closing it.\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"],"description":"`general` counts every request made with the key. `audience` counts\nonly the operations that return subscriber or recipient email\naddresses, and is the tighter of the two.\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` does not move this. Every\noperation in the current contract is a read served out of the\ndatabase, so none of them depends on one. Read `dependencies` for\nthe individual states.\n","enum":["ok","degraded","down"]},"version":{"type":"string","format":"date","description":"The newest contract version this service serves, as a release date.\nA request that sends no `Commune-Version` header is not\nnecessarily on it: an existing key stays pinned to the version that\nwas current when it was issued. Compare the two to find out whether\nan integration 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`.\n","items":{"$ref":"#/components/schemas/Dependency"}}}},"Esp":{"type":"string","title":"Esp","description":"Where a newsletter is published from. `native` 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":["native","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."},"cover_url":{"type":["string","null"],"format":"uri","description":"Wide cover image shown at the top of the profile."},"accent_color":{"type":["string","null"],"description":"The creator's brand colour as a CSS hex triplet, used to tint the\nprofile. Null when they never picked one.\n","examples":["#4f46e5"]},"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 issues 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. Read only here:\nthe API does not create threads.\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 issue is in its life. Only `sent` is ever visible to a public\nkey. An imported article is always `sent`, since Commune sees it after\nthe 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 issue 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 issue.\n"},"newsletter":{"description":"The newsletter this issue 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 issue. 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 issue.\n"},"external_url":{"type":["string","null"],"format":"uri","description":"The issue's canonical URL on the newsletter's own provider, for an\nimported article. Null for a natively published one.\n"},"status":{"$ref":"#/components/schemas/ArticleStatus"},"is_imported":{"type":"boolean","description":"`true` when the issue came in from the newsletter's provider,\n`false` when it was written and sent natively in Commune.\n"},"posted_at":{"type":["string","null"],"format":"date-time","description":"When the issue 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"},"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 issue opened, where its discussion lives.\n`null` when the newsletter does not open a thread per issue. 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 issue 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 a natively published issue 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 issue 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 issue body can fill a\ncontext window on its own.\n\nIt is a conversion of the body rather than of the HTML above.\nFor a natively published issue 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 issue 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 issue has no body, which is different.\n\nNo merge tag ever appears here, resolved or not.\n"}}}],"unevaluatedProperties":false},"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":"`native` 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":["native","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 issues 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 `native`\naddress, where Commune owns the domain and has already done it.\n","items":{"$ref":"#/components/schemas/DnsRecord"}},"verification_error":{"type":["string","null"],"description":"Why verification last failed, in a form the creator can act on. Null\nwhen it has not failed.\n"},"is_default":{"type":"boolean","description":"Whether this is the address the newsletter sends from unless told\notherwise. Exactly one address per newsletter has this.\n"},"verified_at":{"type":["string","null"],"format":"date-time","description":"When the address was first verified. Null if it never was."},"last_checked_at":{"type":["string","null"],"format":"date-time","description":"When Commune last looked at the DNS. Verification is re checked on a\nschedule, so this moves without the creator doing anything.\n"},"created_at":{"type":"string","format":"date-time","description":"When the address was added."},"updated_at":{"type":"string","format":"date-time","description":"When the row last changed."}}},"DomainVerificationStatus":{"type":"string","title":"DomainVerificationStatus","description":"Whether ownership of the domain was proved and a certificate issued.\n`active` does not by itself mean the site is reachable, see\n`routing_ok`.\n","enum":["pending","active","failed"]},"Domain":{"type":"object","title":"Domain","description":"A creator's own domain serving their Commune site. The certificate\nprovider's internal identifier for it is not exposed.\n","additionalProperties":false,"required":["object","id","newsletter","domain","verification_status","created_at"],"properties":{"object":{"type":"string","const":"domain","description":"Always `domain`."},"id":{"type":"string","format":"uuid","description":"Stable identifier."},"newsletter":{"description":"The newsletter the domain serves. A `Ref` unless `newsletter` is\nnamed in `?expand=`.\n","oneOf":[{"$ref":"#/components/schemas/Ref"},{"$ref":"#/components/schemas/Newsletter"}]},"domain":{"type":"string","description":"The hostname, unique across Commune.","examples":["blog.example.org"]},"verification_status":{"$ref":"#/components/schemas/DomainVerificationStatus"},"ssl_status":{"type":["string","null"],"description":"Where the certificate stands, as reported by the provider that\nissues it. Free text rather than an enum, because the vocabulary is\nthe provider's and Commune passes it through.\n"},"dns_target":{"type":["string","null"],"description":"What the creator must point the hostname at."},"verification_records":{"type":"array","description":"The records the creator must publish to prove ownership.","items":{"$ref":"#/components/schemas/DnsRecord"}},"verification_error":{"type":["string","null"],"description":"Why verification last failed. Null when it has not failed."},"routing_ok":{"type":["boolean","null"],"description":"Whether the domain actually resolves to Commune, from an end to end\nrequest rather than from certificate state. Null before the first\nprobe. This is the field that catches a domain whose certificate is\nfine but whose DNS points somewhere else.\n"},"routing_checked_at":{"type":["string","null"],"format":"date-time","description":"When that probe last ran."},"verified_at":{"type":["string","null"],"format":"date-time","description":"When ownership was first proved. Null if it never was."},"last_checked_at":{"type":["string","null"],"format":"date-time","description":"When Commune last reconciled the domain with its provider."},"created_at":{"type":"string","format":"date-time","description":"When the domain was added."}}},"SearchResultType":{"type":"string","title":"SearchResultType","description":"What a search result points at. There is no post or comment kind:\nCommune retired both into chat, so a discussion result is a `thread` or\na `message`.\n","enum":["newsletter","article","user","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 it is there to compare hits within one response\nand to draw a relevance cutoff. It is not comparable across\nqueries or across contract versions, and the scale is not\ndocumented because the ranking is still being tuned.\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/User"},{"$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 issue\naudience scoped: sending to a tag stamps the issue, 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 issue is\nstill addressed to it. Retired tags keep their assignments, because\nthe audience of a sent issue 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."}}},"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 newsletter Commune\nsends natively. For a newsletter connected to an outside provider it\nreflects what Commune last saw of the provider's state.\n","enum":["subscribed","unsubscribed","bounced","complained","pending"]},"Subscriber":{"type":"object","title":"Subscriber","description":"One person's membership of one newsletter. The same person subscribing\nto two newsletters is two subscribers, and one creator never sees the\nother's row.\n\nA subscriber may or may not have a Commune account. Someone who joined\nby email, or who arrived in an import from the newsletter's provider,\nhas an `email` and no `user`. Someone who joined through Commune has\nboth.\n","additionalProperties":false,"required":["object","id","newsletter","email","status","created_at"],"properties":{"object":{"type":"string","const":"subscriber","description":"Always `subscriber`."},"id":{"type":"string","format":"uuid","description":"Stable identifier for this membership."},"newsletter":{"description":"The newsletter subscribed to. A `Ref` unless `newsletter` is named\nin `?expand=`.\n","oneOf":[{"$ref":"#/components/schemas/Ref"},{"$ref":"#/components/schemas/Newsletter"}]},"user":{"description":"The Commune account behind the subscription, or `null` for someone\nwho joined by email without one. A `Ref` unless `user` is named in\n`?expand=`.\n","anyOf":[{"type":"null"},{"$ref":"#/components/schemas/Ref"},{"$ref":"#/components/schemas/User"}]},"email":{"type":"string","format":"email","description":"Where the newsletter reaches this person. Returned on every\nsubscriber row, including one that has a Commune account behind it,\nbecause reconciling a list against the provider that holds it is\nthe job this collection exists for and the people who joined\nthrough Commune are exactly the ones that provider has never heard\nof.\n\nFor a row with no account the address is the one the person\nsubscribed with. For a row with an account it is the address on\nthat account, which is where the issue is actually delivered.\nNever null: a subscription is either an address or an account, and\nan account always carries one.\n\nThis is the one place in the API an address appears. It is a\nproperty of the subscription and not of the person, so it is never\non a public profile, never on an expanded author, and never\nreachable from a key bound to another newsletter. Reading it needs\ncreator scope, counts against a tighter rate limit budget than\neverything else, and writes a row to Commune's audit log naming the\nkey and how many subscribers it read.\n"},"status":{"$ref":"#/components/schemas/SubscriberStatus"},"source":{"type":"string","enum":["commune","imported"],"description":"`commune` when the person subscribed through Commune, `imported`\nwhen they came from the newsletter's provider or a file.\n"},"tags":{"type":"array","description":"The audience tags this subscriber holds, which is what decides which\ntag scoped issues 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 native newsletter, which has no provider to reconcile\nwith.\n"}}},"ThreadVisibility":{"type":"string","title":"ThreadVisibility","description":"Where a thread is placed. `public` puts it on the global Commune feed\nand makes it readable by anyone. `subscribers` keeps it inside the\nnewsletter. `paid` narrows it further to the paying part of the\naudience. Set and changed by the newsletter's team.\n","enum":["public","subscribers","paid"]},"Media":{"type":"object","title":"Media","description":"An image or file attached to a thread or a message.","additionalProperties":false,"required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"Where the attachment is served from."},"type":{"type":["string","null"],"description":"The attachment's media type when Commune recorded one, for example\n`image/png`. Null for an older attachment that predates the field.\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"}]},"is_pinned":{"type":"boolean","description":"Whether the team pinned this thread to the top of the community."},"is_locked":{"type":"boolean","description":"Whether the team closed the thread to new replies."},"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 row that predates short ids.\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"}}},"Reaction":{"type":"object","title":"Reaction","description":"One emoji on one message, aggregated across everyone who left it. There\nis no per person reaction resource: the interesting object is the emoji\nand its tally.\n","additionalProperties":false,"required":["object","emoji","count"],"properties":{"object":{"type":"string","const":"reaction","description":"Always `reaction`."},"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. Each entry is a `Ref` unless\n`users` is named in `?expand=`. Truncated for a heavily reacted\nmessage, in which case `count` is still the full tally.\n","items":{"anyOf":[{"$ref":"#/components/schemas/Ref"},{"$ref":"#/components/schemas/User"}]}}}},"User":{"type":"object","title":"User","description":"A person's public profile. This is the entire shape Commune will ever\nreturn for someone who is not the key holder. Email address, theme,\nnotification preferences, push subscriptions, read state and saved\narticles are deliberately absent.\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"}}},"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=`. Scoring is attributed to a\nCommune account, 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 newsletter\nCommune sends natively, 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. These rows are what the subscriber\ninsight scores are summed from, exposed unaggregated so a consumer can\nbuild its own model instead of accepting Commune's.\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 on purpose,\nand a key present today may be absent tomorrow, so read it\ndefensively.\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\nnewsletter Commune sends natively.\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 issues, never counts of emails.\n","required":["sent","scheduled"],"properties":{"sent":{"type":"integer","minimum":0,"description":"Issues that went out inside the window and that Commune has a\nrecord of. Commune's article table is filtered rather than\nexhaustive, so this must never be presented as the number of\nemails the newsletter sent. That number lives on a send.\n"},"scheduled":{"type":"integer","minimum":0,"description":"Issues queued to go out after `period_end`. A forward looking\ncount, so it does not move when the window does.\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","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 issues 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 issue.\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, and\nhow Commune's own invitations to the list performed.\n","additionalProperties":false,"required":["object","newsletter","period_start","period_end","by_source","invites"],"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":"How these people arrived. `commune` means they joined through\nCommune itself. Any other value names the provider or the file\nthey came from. `null` for rows written before Commune\nrecorded a source, which cannot be attributed after the fact.\n","examples":["commune"]},"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"}}}},"invites":{"type":"object","additionalProperties":false,"description":"Commune's invitations to the people on the list who do not have a\nCommune account yet, asking them to join the conversation around the\nnewsletter they already read.\n\nThis funnel is ring fenced from the subscription. Declining an\ninvitation leaves the newsletter subscription untouched, so nothing\nhere is an unsubscribe signal.\n","required":["sent","accepted","opted_out"],"properties":{"sent":{"type":"integer","minimum":0,"description":"Invitations Commune sent inside the window."},"accepted":{"type":"integer","minimum":0,"description":"Invited people who created a Commune account inside the window.\nAttributed to the window they accepted in, not the one they were\ninvited in, so the two numbers describe different cohorts and\ndividing one by the other is not a conversion rate.\n"},"opted_out":{"type":"integer","minimum":0,"description":"Invited people who asked for no further invitations inside the\nwindow. They still receive the newsletter.\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 issue measured on both sides at once: what the email did, and what\nthe community did with it afterwards.\n\nNamed for what it answers rather than for its path, because\n`ArticleStats` is already the small public tally that hangs off an\narticle itself. This is the creator scoped report, and the two are not\ninterchangeable.\n","additionalProperties":false,"required":["object","article","email","community"],"properties":{"object":{"type":"string","const":"article_stats","description":"Always `article_stats`."},"article":{"description":"The issue 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 issue 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 issue 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 issue, 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 issue on Commune, counted once each rather\nthan once per visit.\n"},"likes":{"type":"integer","minimum":0,"description":"People who liked the issue."},"saves":{"type":"integer","minimum":0,"description":"People who put the issue 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 issue.\n"},"thread_messages":{"type":"integer","minimum":0,"description":"Replies in the issue's discussion. Commune has no separate\ncomments store: an issue's discussion is a chat thread like any\nother, so this counts the undeleted replies hanging off it, and\nit is `0` for an issue 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 issue started a\nconversation or an argument between two people.\n"}}}}},"Actor":{"type":["object","null"],"description":"Who caused the change. ALWAYS `null` today and consumers must treat it that way: the public API is currently read-only, so nothing that reaches this catalog was triggered through it, and Commune does not backfill an actor for changes made in the product UI or by a cron. The field exists now so that populating it, once writes exist, is an additive change rather than a new envelope version.","additionalProperties":false,"required":["type","id"],"properties":{"type":{"type":"string","enum":["user","api_key","system"],"description":"`user` for a change a person made through an authenticated session, `api_key` for a public API write, `system` for a cron or webhook ingest with no human behind it."},"id":{"type":"string","description":"Identifier of the actor within its `type`. A user id for `user`, a key id (never the secret) for `api_key`, a stable job name for `system`."},"label":{"type":["string","null"],"description":"Human-readable name for display. Best effort, may be null."}}},"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":["native_send","import"],"description":"`native_send` for an issue 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 deliberately not here. Read the article back from the REST API 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"}}}]},"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 lands on a five-minute boundary because that is the granularity the dispatch cron picks up."}}},"ArticleScheduledEvent":{"title":"Article scheduled","description":"A native issue was queued for a future send.","allOf":[{"$ref":"#/components/schemas/EventEnvelope"},{"type":"object","properties":{"type":{"const":"article.scheduled"},"data":{"$ref":"#/components/schemas/ArticleScheduledData"}}}]},"BillingSubscriptionUpdatedData":{"type":"object","required":["subscription_id","plan","status"],"properties":{"subscription_id":{"type":"string","format":"uuid","description":"Commune's own `newsletter_subscriptions` row, one per native newsletter. Not a Stripe id. Stripe identifiers are not 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"}}}]},"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 row settled as 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 `article_deliveries` row, identified against both Commune's ids and the provider's message id so a consumer can reconcile with its own provider logs.","required":["delivery_id","article_id","send_id","subscriber_id","email","status"],"properties":{"delivery_id":{"type":"string","format":"uuid","description":"The `article_deliveries` row."},"article_id":{"type":"string","format":"uuid"},"send_id":{"type":"string","format":"uuid","description":"The send run this delivery belonged to."},"subscriber_id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email","description":"The address as it was at send time. Snapshotted onto the delivery row, so it does not follow a later change to the subscriber."},"provider_message_id":{"type":["string","null"],"description":"The email provider's id for this message, the correlator the inbound webhook matched on. Null only if the row was written without one."},"status":{"type":"string","enum":["queued","sent","delivered","opened","clicked","bounced","complained","failed"],"description":"The delivery row's status after this event was applied. Because the state machine is forward-only, it is not always the event's own name: an open recorded after a click leaves the row on `clicked`."}}},"DeliveryOpenedEvent":{"title":"Delivery opened","description":"The recipient opened the message.","allOf":[{"$ref":"#/components/schemas/EventEnvelope"},{"type":"object","properties":{"type":{"const":"delivery.opened"},"data":{"$ref":"#/components/schemas/DeliveryEventData"}}}]},"DomainVerifiedData":{"type":"object","required":["domain_id","domain","verified_at"],"properties":{"domain_id":{"type":"string","format":"uuid"},"domain":{"type":"string","description":"The hostname now serving the newsletter's site. Always a subdomain: apex domains cannot CNAME and are rejected before they get this far."},"ssl_status":{"type":["string","null"],"description":"The certificate authority's sub-status, kept for display. The aggregate `active` state is what Commune actually branches on."},"verified_at":{"type":"string","format":"date-time"}}},"DomainVerifiedEvent":{"title":"Custom website domain verified","description":"A creator's custom website domain went live.","allOf":[{"$ref":"#/components/schemas/EventEnvelope"},{"type":"object","properties":{"type":{"const":"domain.verified"},"data":{"$ref":"#/components/schemas/DomainVerifiedData"}}}]},"EventEnvelope":{"type":"object","title":"Event envelope","description":"The shape every message on every topic shares. The concrete event schemas below narrow `type` and fill in `data`; nothing else varies.\n\nReserved nulls: `actor` and `idempotency_key` are declared but never populated. They are typed nullable rather than omitted so that a consumer written today keeps validating unchanged once they start being filled in. Do not treat their absence of value as a signal.\n\nDeduping: `id` is unique per event and stable across redeliveries of that event, which makes it the correct dedupe key. Once `idempotency_key` is populated, it will additionally let a consumer collapse the events caused by one retried write, which `id` cannot do.","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. It is the primary key of the row the publisher wrote, which means it is assigned when the state change is recorded, not 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 21 topics currently are."},"actor":{"$ref":"#/components/schemas/Actor"},"idempotency_key":{"type":["string","null"],"maxLength":255,"description":"ALWAYS `null` today. Once the public API accepts writes this carries the `Idempotency-Key` header of the write that caused the change, so a consumer can tie an event back to its own request and collapse the duplicates a retried write would otherwise produce. Null stays a legitimate value even then: a change made in the product UI or by a cron has no originating request to key on."},"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. Deliberately no `user_id`: Commune does not attribute a highlight to a named reader and neither does this event.","required":["highlight_id","article_id","quote","start_offset","end_offset","owner_key","message_id","created_at"],"properties":{"highlight_id":{"type":"string","format":"uuid","description":"The `article_highlights` row."},"article_id":{"type":"string","format":"uuid"},"quote":{"type":"string","maxLength":5000,"description":"The marked text itself, as plain text, capped at 5000 characters on write. The surrounding prefix and suffix that let a client re-anchor the range are not here; read the highlight back from the REST API 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 the REST resource 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 moves a newsletter onto native sending, 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":"Rows that actually landed. Not the size of what was offered: both import paths skip duplicates, and the subscriber upsert preserves the status of a row that already existed rather than overwriting it. Null for `migration`, which ingests nothing.\n\nRows the run could not take are not reported. Per-row failures are swallowed so one bad row cannot end the run, and none of the ingest paths counts them apart from the duplicates it skipped on purpose, so a count 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"}}}]},"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":["native","custom"],"description":"`native` 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 issue does not pin a specific one."},"verified_at":{"type":"string","format":"date-time"}}},"SenderVerifiedEvent":{"title":"Sender verified","description":"A sending identity passed verification and can now send.","allOf":[{"$ref":"#/components/schemas/EventEnvelope"},{"type":"object","properties":{"type":{"const":"sender.verified"},"data":{"$ref":"#/components/schemas/SenderVerifiedData"}}}]},"SubscriberCreatedData":{"type":"object","required":["subscriber_id","email","status","resubscribed","created_at"],"properties":{"subscriber_id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"status":{"type":"string","enum":["subscribed","unsubscribed","bounced","complained","pending"]},"user_id":{"type":["string","null"],"description":"The Commune account behind this subscriber, when there is one. Null for an address imported from an ESP or CSV that never signed up."},"acquisition_source":{"type":["string","null"],"description":"How a subscriber was imported, frozen at insert 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, and therefore null for a subscriber who subscribed in the Commune app, finished signup, or accepted an invitation. Two further values, `commune` and `imported`, appear only on rows that predate this field and were labelled once when it was introduced; nothing writes either of them today."},"resubscribed":{"type":"boolean","description":"True when an existing unsubscribed row was flipped back to `subscribed` rather than a new row inserted."},"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 deliberately not the insight resource: 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 worth delivering rather than a gap in the data."},"status":{"$ref":"#/components/schemas/InsightStatus"},"direction":{"type":"string","enum":["promoted","demoted"],"description":"Which way along the ladder the reader moved, derived from the two statuses so a consumer does not have to hardcode their order. `promoted` the first time a reader is scored, because there is no earlier position they could have fallen from."},"total_score":{"type":"integer","description":"The blended score at the moment of the crossing, summed across the community and the newsletter's email provider. It has no unit and no ceiling, and is meaningful only ranked against the other readers of the same newsletter."},"velocity":{"type":"string","enum":["rising","cooling","steady"],"description":"The last fourteen days of points against the fourteen before them. Read it with `direction`: a demotion while `rising` means the audience around this reader moved faster, not that the reader slowed down."},"last_action_at":{"type":["string","null"],"format":"date-time","description":"When the reader last did anything that earned points. The reader's own clock, not the scoring pass's, so it sits earlier than `occurred_at` by up to a full scoring interval. Null for a reader who has never acted."}}},"SubscriberStatusChangedEvent":{"title":"Subscriber insight status changed","description":"A reader crossed a boundary in the newsletter's engagement ladder.","allOf":[{"$ref":"#/components/schemas/EventEnvelope"},{"type":"object","properties":{"type":{"const":"subscriber.status_changed"},"data":{"$ref":"#/components/schemas/SubscriberStatusChangedData"}}}]},"SubscriberTaggedData":{"type":"object","title":"Subscriber tagged body","description":"Body of `subscriber.tagged`. Which subscriber, which tag, and which way the membership moved.","required":["subscriber_id","tag_id","tag_name","direction"],"properties":{"subscriber_id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"tag_id":{"type":"string","format":"uuid"},"tag_name":{"type":"string","description":"The tag's name at the time of the change."},"direction":{"type":"string","enum":["assigned","removed"],"description":"Whether the subscriber joined the segment or left it. Branch on this: it is the only thing that differs between the two changes this topic carries."}}},"SubscriberTaggedEvent":{"title":"Subscriber tag added or removed","description":"A subscriber tag was applied or taken off.","allOf":[{"$ref":"#/components/schemas/EventEnvelope"},{"type":"object","properties":{"type":{"const":"subscriber.tagged"},"data":{"$ref":"#/components/schemas/SubscriberTaggedData"}}}]},"SubscriberUnsubscribedData":{"type":"object","required":["subscriber_id","email","reason","unsubscribed_at"],"properties":{"subscriber_id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"reason":{"type":"string","enum":["self_service","bounced","complained"],"description":"`self_service` is the reader opting out. `bounced` and `complained` are forced by delivery telemetry and arrive with the matching `delivery.*` event."},"unsubscribed_at":{"type":"string","format":"date-time"},"article_send_id":{"type":["string","null"],"format":"uuid","description":"The send whose email the reader acted on, when the unsubscribe link carried it. Best-effort attribution: null on an in-app unsubscribe, a link without the marker, or a bounce or complaint."}}},"SubscriberUnsubscribedEvent":{"title":"Subscriber unsubscribed","description":"A subscriber stopped being mailable.","allOf":[{"$ref":"#/components/schemas/EventEnvelope"},{"type":"object","properties":{"type":{"const":"subscriber.unsubscribed"},"data":{"$ref":"#/components/schemas/SubscriberUnsubscribedData"}}}]},"ThreadCreatedData":{"type":"object","required":["thread_id","visibility","content","created_at"],"properties":{"thread_id":{"type":"string","format":"uuid","description":"The thread itself, which is the opening message of the conversation rather than a reply within one."},"short_id":{"type":["string","null"],"description":"Short public identifier used in thread URLs."},"url":{"type":["string","null"],"format":"uri"},"author":{"$ref":"#/components/schemas/UserRef"},"content":{"type":"string","maxLength":5000,"description":"The opening message, capped at 5000 characters on write."},"visibility":{"type":"string","enum":["public","subscribers","paid"],"description":"`public` puts the thread on the global feed and only team members may set it. `subscribers` keeps it in the newsletter's own space. `paid` is reserved and unused."},"article_id":{"type":["string","null"],"format":"uuid","description":"Set when the thread is an article's discussion. This is where article comments live now; the old `article_comments` table is dead."},"created_at":{"type":"string","format":"date-time"}}},"ThreadCreatedEvent":{"title":"Thread created","description":"A new top-level thread was started in a newsletter's space.","allOf":[{"$ref":"#/components/schemas/EventEnvelope"},{"type":"object","properties":{"type":{"const":"thread.created"},"data":{"$ref":"#/components/schemas/ThreadCreatedData"}}}]},"ThreadPublishedData":{"type":"object","title":"Thread published body","description":"Body of `thread.published`. Which thread reached the feed, how it got there, and enough to render a feed row without a second call.","required":["thread_id","visibility","previous_visibility","source","published_at"],"properties":{"thread_id":{"type":"string","format":"uuid","description":"The thread. The same id `thread.created` carried, so a consumer that stored that event can match on it directly."},"short_id":{"type":["string","null"],"description":"Short public identifier used in thread URLs."},"url":{"type":["string","null"],"format":"uri"},"author":{"$ref":"#/components/schemas/UserRef"},"visibility":{"type":"string","const":"public","description":"Always `public`. Present so a payload stays self-describing next to `thread.created`, which carries the same field and can carry other values."},"previous_visibility":{"type":["string","null"],"enum":["subscribers","paid",null],"description":"What the thread was before. Null when it was created public, which is the case `source` also reports."},"source":{"type":"string","enum":["created_public","visibility_changed"],"description":"`created_public` for a thread a team member started public, which fires `thread.created` from the same write. `visibility_changed` for one that was featured later."},"published_by":{"allOf":[{"$ref":"#/components/schemas/UserRef"}],"description":"The team member who put it on the feed. Named `published_by` rather than `actor` because the envelope already has an `actor`, which means something different and is always null today. Null on the `created_public` path, where a thread was public from the start and nobody featured it."},"article_id":{"type":["string","null"],"format":"uuid","description":"Set when the thread is an article's discussion rather than a standalone one."},"published_at":{"type":"string","format":"date-time","description":"`visibility_changed_at` on the `visibility_changed` path, the thread's `created_at` on the `created_public` path."}}},"ThreadPublishedEvent":{"title":"Thread published to the feed","description":"A thread's visibility became public and it hit the global feed.","allOf":[{"$ref":"#/components/schemas/EventEnvelope"},{"type":"object","properties":{"type":{"const":"thread.published"},"data":{"$ref":"#/components/schemas/ThreadPublishedData"}}}]},"UserRef":{"type":["object","null"],"title":"User reference","description":"A pointer to a Commune account, enough to attribute and render something without a second call. Null when there is no account to point at, either because there never was one or because the account was deleted. Content outlives its author, so a null here is expected rather than a sign the reference was lost.","required":["user_id"],"properties":{"user_id":{"type":"string"},"username":{"type":["string","null"]},"display_name":{"type":["string","null"]},"avatar_url":{"type":["string","null"],"format":"uri"}}}}},"externalDocs":{"description":"Guides, getting started, authentication and the webhook verification walkthrough. This reference describes every operation; the guides describe how to string them together.","url":"https://usecommune.com/docs"},"x-tagGroups":[{"name":"Newsletters and people","tags":["Newsletters","Team","Users","Search"]},{"name":"Publishing","tags":["Articles","Highlights"]},{"name":"Community","tags":["Threads","Messages"]},{"name":"Audience","tags":["Subscribers","Subscriber tags"]},{"name":"Insights","tags":["Engagement","Metrics"]},{"name":"Sending and domains","tags":["Senders","Website domains"]},{"name":"Platform","tags":["Platform","Webhooks"]}]}