openapi: 3.2.0 info: title: Usecommune Articles API version: '2026-08-26' contact: name: Commune url: https://usecommune.com email: support@usecommune.com description: 'Operations tagged Articles across 2 of this provider''s published API definitions: usecommune-openapi.json, usecommune-openapi.yml. Each path carries the servers of the definition it was published in.' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' security: - apiKey: [] tags: - name: Articles description: 'An article is one thing a newsletter published: written in Commune and sent, or imported from the newsletter''s provider.' paths: /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 `posted_at` descending with articles that never got a date last. Two gates apply and neither can be turned off. An article stamped with an audience is returned only to a credential that may read that audience: the newsletter''s owner, an admin or editor, or a subscriber holding one of the article''s tags. An article whose `posted_at` is in the future is not returned at all until that moment passes, so a scheduled article never leaks early through this collection. `content` is never included here, whatever `?fields=` asks for. An article body is large enough that returning a page of them is the wrong default, so read it from `GET /articles/{article}`.' tags: - Articles security: - apiKey: [] - oauth2: - content:read parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Expand' - $ref: '#/components/parameters/Fields' - name: status in: query required: false description: 'Return only articles in this state. A credential holding only `read` permissions may ask for `sent` alone, since a draft or a failed send is not published. Repeat the parameter to accept several. ' schema: $ref: '#/components/schemas/ArticleStatus' - name: imported in: query required: false description: '`true` returns only articles imported from the newsletter''s provider, `false` only articles written in Commune. Omit for both. ' schema: type: boolean - name: tag in: query required: false description: 'Return only articles stamped with this subscriber tag, by tag `id`. Needs `content: read`, because the audience of an article is not public. ' schema: type: string format: uuid responses: '200': description: A page of articles, each without `content`. content: application/json: schema: allOf: - $ref: '#/components/schemas/ListEnvelope' - type: object properties: data: type: array description: 'This page of the newsletter''s articles, most recently published first by `posted_at`, with articles that never got a date last. Two gates remove rows before the page is built, and neither is marked in the response: an article dated in the future is absent until that moment passes, and an article stamped with an audience is absent unless the credential may read that audience. A page shorter than expected is those gates rather than an error. No entry carries `content`, whatever `?fields=` asked for. ' items: $ref: '#/components/schemas/Article' examples: twoRecentArticles: summary: An article written in Commune and an imported one, newest first, neither carrying a body. value: object: list data: - object: article id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f short_id: k7Rm2xQp slug: what-newsletters-get-wrong-about-community newsletter: object: newsletter id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411 title: What newsletters get wrong about community preview_text: The moderation load is the product, not a tax on it. image_url: https://cdn.example.com/articles/k7Rm2xQp/cover.png external_url: null status: sent is_imported: false posted_at: '2026-08-26T09:32:11Z' scheduled_for: null authors: - object: user id: usr_2Nf8Kq1pWc thread: object: thread id: b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9 stats: likes: 148 comments: 27 highlights: 63 created_at: '2026-08-24T11:04:52Z' updated_at: '2026-08-26T09:32:11Z' - object: article id: 5b7a1d90-2c34-4e18-9f6b-8d0a1c2b3e4f short_id: q4Ts9wLm slug: the-week-we-stopped-chasing-opens newsletter: object: newsletter id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411 title: The week we stopped chasing opens preview_text: null image_url: null external_url: https://example.com/p/the-week-we-stopped-chasing-opens status: sent is_imported: true posted_at: '2026-08-19T09:30:00Z' scheduled_for: null authors: - object: user id: usr_5Qw8Hn2vFd thread: null stats: likes: 61 comments: 9 highlights: 14 created_at: '2026-08-26T20:21:09Z' updated_at: '2026-08-26T20:21:09Z' pagination: has_more: true next_cursor: Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4 '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' post: operationId: createArticle summary: Create an article description: 'Writes a new article and returns it. Needs `content: write`, and is a write. The article''s text goes in `content_markdown`, as Markdown: the same rendition `GET /articles/{article}` returns as `content_markdown` under `?expand=content`, so what you read back is what you send. HTML is not accepted. Commune parses it strictly: text it cannot read answers `400` naming the line, and nothing is written, rather than guessing at a document you did not write and sending it to your list. **What you get is always a draft.** `status`, `posted_at` and `scheduled_for` are not properties of the request. A draft is invisible on every reader surface, so nothing this operation does reaches anybody. It goes out through Schedule an article (`POST /articles/{article}/schedule`) or Send an article to the list (`POST /articles/{article}/send`), each of which runs the six gates described on the second before its email leaves Commune. **Send no body at all and Commune seeds one.** `{}` creates an empty untitled draft: two blank lines and an editable unsubscribe line. Send a body and it is stored exactly as sent, with nothing appended. The send operations refuse an article whose body carries no unsubscribe mechanism and no postal address, so a body you intend to send should carry `{{ unsubscribe_url }}` and `{{ address }}`. **Only a newsletter Commune publishes.** A newsletter whose `esp` is anything but `commune` has its articles written elsewhere and mirrored into Commune afterwards, so there is nothing here to create. That answers `422` with the code `not_commune_newsletter`, whose `docs_url` is `https://usecommune.dev/errors/not_commune_newsletter`, the page on what the refusal means and how to move a newsletter onto Commune. Publishes no event. A draft has neither gone out nor been queued, and `status` is what says so until one of those happens. ## The Markdown `content_markdown` takes Headings, paragraphs, bold, italic, strikethrough, inline code, links, images, blockquotes, bullet and ordered lists, fenced code blocks with a language, tables and thematic breaks. A line ending in two spaces or a backslash is a line break; a code fence without a language is stored without one rather than guessed at. Merge tags survive exactly as written. `{{ subscriber.first_name }}` and `{% if %}` are personalization rather than Markdown, so nothing inside a Liquid construct is escaped or read as formatting. Raw HTML is refused rather than passed through or dropped. Write a literal `` ... `` wraps blocks in a styled band. Optional `backgroundColor`, `textColor` (hex, with the `#`), `fontFamily` (`sans`, `serif`, `mono`), `fontSize` (a number, in px) and `textAlign` (`left`, `center`, `right`). * `` ... `` wraps blocks that belong in the inbox and not on the web. This is where the unsubscribe line and the mailing address go: on the website there is no subscriber, so the link is dead and the address is noise. Commune seeds exactly this into a draft created with no body. * `Label` is a call to action. `href` is required; `alignment` is optional. * `` is a row of linked platform icons. `items` is required and is a JSON array of `{"platform": "...", "url": "...", "imageUrl": null}`. Optional `align`, `iconColor` and `iconBgColor`. * `` is a video. The URL has to be one Commune can read a video id out of (`youtube.com/watch?v=`, `youtu.be/`, `youtube.com/shorts/` or `youtube.com/embed/`), because the email shows a thumbnail built from that id rather than an iframe, which every major email client strips. Attributes are written `name="value"` or `name={json}`. A component that holds nothing is written self-closing; one that holds content is opened and closed on their own lines.' tags: - Articles security: - apiKey: [] - oauth2: - content:write parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ArticleCreateRequest' responses: '201': description: 'The article, as created: a draft, with the `id`, `short_id` and `slug` every other operation addresses it by. Read it back with `GET /articles/{article}` to see the stored body. ' content: application/json: schema: $ref: '#/components/schemas/Article' examples: newDraft: summary: A draft with a title and a body, not yet scheduled. value: object: article id: 8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012 short_id: Vn3Pq8Zt slug: what-we-learned-in-march newsletter: object: newsletter id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411 title: What we learned in March preview_text: The third one surprised us. image_url: null external_url: null status: draft is_imported: false posted_at: null scheduled_for: null authors: - object: user id: usr_2Nf8Kq1pWc thread: null stats: likes: 0 comments: 0 highlights: 0 created_at: '2026-09-18T10:22:04Z' updated_at: '2026-09-18T10:22:04Z' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/Unprocessable' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /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 that returns a body. The same two gates apply as on the list. An article stamped with an audience answers `404` to a credential that does not hold that audience, and a future dated article answers `404` until it goes live, including to the newsletter''s own team, so that a preview link cannot be shared early. For an article written in Commune, `content` is the email rendered to HTML with personalization placeholders resolved against an empty context, so a merge tag never leaks as raw text. For an imported article it is the body as it arrived from the provider. `?expand=content` adds `content_markdown`, the same body as Markdown. Ask for it when a model is going to read the article, and ask for `?fields=content_markdown` with it to leave the HTML behind entirely.' tags: - Articles security: - apiKey: [] - oauth2: - content:read parameters: - $ref: '#/components/parameters/Expand' - $ref: '#/components/parameters/Fields' responses: '200': description: The article, with `content`. content: application/json: schema: $ref: '#/components/schemas/ArticleWithContent' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' patch: operationId: updateArticle summary: Edit an article description: 'Changes an article that already exists. Needs `content: write`, and is a write. **Absent and null are different.** A property you leave out is left alone; a property you send as `null` is cleared. Send at least one property: an empty object answers `400`. **`content_markdown` replaces the article''s text in full.** Send the whole article, not just the part that changed. It can''t be `null`: an article always has text. It takes the same Markdown as Create an article (`POST /newsletters/{newsletter}/articles`), which documents it, and is parsed as strictly: text Commune cannot read answers `400` naming the line, and nothing is written. **This never moves the article''s state.** There is no `status` here, no `posted_at`, no `scheduled_for`. Queueing, cancelling, sending and retrying are their own operations. Neither is the byline: who is credited on an article cannot be changed here. ## Which articles may be edited A draft, a scheduled article, and one whose send failed. Everything else answers `422` with a message saying which case it is: * **being sent right now**: an edit mid send would reach part of the list and not the rest. Wait for it to finish. * **sent**: the email cannot be recalled, so an edit would change only the web page. * **archived**: unarchive it in Commune first. * **imported**: the body is a copy of what the newsletter''s provider published and cannot be edited here. **Only a newsletter Commune publishes.** Anything but a `commune` newsletter has its articles written elsewhere, and answers `422` with the code `not_commune_newsletter`, carrying a `docs_url` of `https://usecommune.dev/errors/not_commune_newsletter`. Editing the body clears the HTML `content` returns until the article is next opened in Commune''s editor. `content_markdown` is rendered from the article itself and is correct immediately. Publishes no event. The article''s `updated_at` is what says it changed.' tags: - Articles security: - apiKey: [] - oauth2: - content:write parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ArticleUpdateRequest' responses: '200': description: 'The article, as it now stands. Without `content`: read it back with `GET /articles/{article}` when you want the stored body. ' content: application/json: schema: $ref: '#/components/schemas/Article' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/Unprocessable' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /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. An article whose author was never mapped to a Commune account returns an empty page rather than a placeholder person.' tags: - Articles security: - apiKey: [] - oauth2: - content:read parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Fields' responses: '200': description: A page of authors, in byline order. content: application/json: schema: allOf: - $ref: '#/components/schemas/ListEnvelope' - type: object properties: data: type: array description: 'Everyone credited on this article''s byline, in the order the creator arranged them rather than by time. Usually one entry; an imported article attributed to several people through the provider''s creator field, or a co-signed Commune article, has more. Empty for an article whose byline was never mapped to a Commune account, which is an ordinary outcome for an imported article rather than an error. ' items: $ref: '#/components/schemas/User' examples: coSignedArticle: summary: Two people on one byline, in the order the creator arranged them. value: object: list data: - object: user id: usr_2Nf8Kq1pWc username: mira display_name: Mira Okafor avatar: https://cdn.example.com/avatars/mira.png - object: user id: usr_5Qw8Hn2vFd username: sam display_name: Sam Ortega avatar: null pagination: has_more: false next_cursor: null '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /articles/{article}/images: parameters: - $ref: '#/components/parameters/CommuneVersion' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/ArticlePath' post: operationId: createArticleImage summary: Store an image for an article description: 'Stores an image for this article and answers with the URL it is served at, so whatever writes the article never needs somewhere of its own to host one. Needs `content: write`. Two ways in, chosen by the body: - **`source_url`**: Commune downloads the image and stores a copy. For an image you hold as a link, including a generated one on a link that will expire. It has to be a public http or https URL on the standard port; private and local network addresses are refused, redirects included. - **`content_type`**: Commune answers with a one-time `upload` URL and the image''s final `url`. `PUT` the file''s bytes to `upload.url` with the `upload.headers`, before `upload.expires_at`. The bytes go straight to storage, so a file on disk never has to pass through a model or through this API. Until the upload is made, `url` answers `404`. Either way the image is a JPEG, PNG, WebP or GIF of at most 10MB, and the article itself is not changed: put the `url` in the body''s Markdown (`!alt`) or in `image_url` with Update an article (`PATCH /articles/{article}`).' tags: - Articles security: - apiKey: [] - oauth2: - content:write requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ArticleImageRequest' responses: '201': description: 'The stored image, or for an upload, where it will be once the file is uploaded. ' content: application/json: schema: $ref: '#/components/schemas/ArticleImage' examples: imported: summary: Downloaded from `source_url` and stored. value: object: article_image url: https://project.supabase.co/storage/v1/object/public/article-images/7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411/c0ffee00-1111-4a4a-8b8b-123456789abc/1790000000000-k3j9x2a1.png content_type: image/png size_bytes: 48213 source_url: https://example.com/chart.png upload: null upload: summary: Ready for you to upload the file yourself. value: object: article_image url: https://project.supabase.co/storage/v1/object/public/article-images/7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411/c0ffee00-1111-4a4a-8b8b-123456789abc/1790000000000-p0q8w7e2.jpg content_type: image/jpeg size_bytes: null source_url: null upload: url: https://project.supabase.co/storage/v1/object/upload/sign/article-images/7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411/c0ffee00-1111-4a4a-8b8b-123456789abc/1790000000000-p0q8w7e2.jpg?token=eyJhbGciOi... method: PUT headers: Content-Type: image/jpeg max_bytes: 10485760 expires_at: '2026-10-01T18:00:00Z' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/Unprocessable' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' '503': $ref: '#/components/responses/ServiceUnavailable' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /articles/{article}/test-send: parameters: - $ref: '#/components/parameters/CommuneVersion' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/ArticlePath' post: operationId: sendArticleTest summary: Send a test copy of an article description: 'Sends this article to addresses you name, or to yourself when you name none, so somebody can look at it before the list does. Needs `sending: write`. Leave out `to` (or the whole body) and the copy goes to the person the credential belongs to. Their address is not echoed back: the answer says `sent_to_owner: true` and `recipients` is empty, because an email address is revealed only by `GET /me`. **Nobody on the list receives anything.** No subscriber is touched, no delivery is recorded, the article''s status does not move and no event is published. Run it as often as you like on the same draft: it changes nothing about the article, so a repeat is a second look rather than a second send. The article is rendered the way a real send renders it, with three differences. Personalization placeholders are filled with example data rather than left as raw text. The unsubscribe link points at a page that explains itself rather than at a live token, so a forwarded test cannot unsubscribe a real person. And the subject is prefixed, so a test is never mistaken for the article. **Three of the six send gates apply here.** There has to be an article, a verified sending address, and something in the body. The footer required on commercial email, the article''s status and the image reachability check do not refuse a test, so a test is where you go to see that the footer has gone. Send an article to the list (`POST /articles/{article}/send`) refuses on all six. Test sends count towards the same daily sending allowance real sends do.' tags: - Articles security: - apiKey: [] - oauth2: - sending:write requestBody: required: false content: application/json: schema: $ref: '#/components/schemas/TestSendRequest' responses: '200': description: 'What was attempted and what the sending provider accepted. `sent` plus `failed` is always the number of addresses the copy went to, and an address named twice is only sent once. ' content: application/json: schema: $ref: '#/components/schemas/TestSend' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/Unprocessable' '429': $ref: '#/components/responses/SendLimited' '500': $ref: '#/components/responses/InternalError' '503': $ref: '#/components/responses/ServiceUnavailable' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /articles/{article}/schedule: parameters: - $ref: '#/components/parameters/CommuneVersion' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/ArticlePath' post: operationId: scheduleArticle summary: Schedule an article description: 'Queues this article to go out at a time you choose. Only a draft or an already scheduled article can be scheduled, and `scheduled_for` has to be in the future. Scheduling an already scheduled article moves it, which is how a send time is changed: there is no separate reschedule. **A scheduled article is not published.** Its `status` becomes `scheduled` and `posted_at` stays null, so it stays invisible on every reader surface until it goes out. The newsletter''s own credentials can read it, which is how you check what is queued. **Every gate Send an article to the list (`POST /articles/{article}/send`) runs is run here**, so a missing footer, an unreachable image, an unverified sending address or an empty body refuses the schedule now rather than failing silently at six in the morning. The time is honoured to within a few minutes, not to the second. Commune dispatches queued articles in passes, so `scheduled_for` is the moment from which an article may go out rather than the moment it will. Publishes `article.scheduled`.' tags: - Articles security: - apiKey: [] - oauth2: - sending:write requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ScheduleRequest' responses: '200': description: 'The article, as it now stands: `status` is `scheduled` and `scheduled_for` is the time it will go out from. ' content: application/json: schema: $ref: '#/components/schemas/Article' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/Unprocessable' '429': $ref: '#/components/responses/SendLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /articles/{article}/unschedule: parameters: - $ref: '#/components/parameters/CommuneVersion' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/ArticlePath' post: operationId: unscheduleArticle summary: Cancel a scheduled article description: 'Takes a queued article back off the schedule. Needs `sending: write`. The article returns to `draft` and its send time is cleared, so nothing will dispatch it until it is scheduled or sent again. It was never visible to readers while it was queued, so nothing a reader sees changes. **An article that is not scheduled answers `422`** rather than succeeding quietly. `draft` and `sent` are both "not scheduled", and a caller cancelling a send needs to know which one it is looking at: an article that has already gone out cannot be recalled. **Publishes no event.** A consumer told the article was scheduled gets no second event saying it no longer is, so read `status` on the article to know where it stands.' tags: - Articles security: - apiKey: [] - oauth2: - sending:write responses: '200': description: 'The article, back as a draft, with `scheduled_for` null. ' content: application/json: schema: $ref: '#/components/schemas/Article' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/Unprocessable' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /articles/{article}/send: parameters: - $ref: '#/components/parameters/CommuneVersion' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/ArticlePath' post: operationId: sendArticle x-commune-destructive: Emails the article to the list. Mail that has left cannot be recalled, so a client should confirm with a person before calling this. summary: Send an article to the list description: 'Sends this article to the newsletter''s subscribers. Cannot be undone. **It answers before the article has gone out.** The article is queued for immediate dispatch and the answer is `202` with the article as it now stands: `status` is `scheduled` and `scheduled_for` is the moment it was queued. Commune begins sending within a few minutes. Watch `send.completed` for the outcome and the counts, or `send.failed` if the dispatch broke. `article.published` fires when the article goes live on the web. All three carry the credential that asked for the send and the idempotency key it was made under, so a consumer can tie them back to this call. **Six gates.** Four are simple: there has to be an article, a verified sending address, something in the body, and a status that can be sent from. An article that is already sending or sent answers `422`. The other two are worth knowing about before you call this: * **`missing_footer`** (`422`) when the body no longer carries an unsubscribe link or a mailing address. Both are seeded into a draft as ordinary content and can be edited away, and commercial email is required to carry them. The error names which half is gone. * **`broken_images`** (`422`) when an image definitively will not load, naming the URLs and why each one failed. An inbox fetches images when the reader opens the article, so a rotted image is broken for everybody and cannot be repaired after the send. Only a definite answer refuses: a merely slow host is reported and never blocks. Set `acknowledge_broken_images` to send anyway. That acknowledgement covers the body as it currently reads and any edit clears it. An article addressed to a tag goes only to the subscribers holding it. An article with no subscribers to send to is still published: it goes live on the web and reports zero recipients. **Failed deliveries are Commune''s to follow up.** A recipient the sending provider turns away for a moment is retried automatically during the send. One that still fails is followed up by Commune''s team rather than re-sent blindly, because some of those may already have been delivered and a second copy is worse than a late one. Publishes `article.scheduled` when the article is queued, then `article.published` and `send.completed` or `send.failed` when the dispatch runs.' tags: - Articles security: - apiKey: [] - oauth2: - sending:write requestBody: required: false content: application/json: schema: $ref: '#/components/schemas/SendRequest' responses: '202': description: 'The article is queued. `status` is `scheduled` and `scheduled_for` is the moment it was queued; sending begins within a few minutes. ' content: application/json: schema: $ref: '#/components/schemas/Article' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/Unprocessable' '429': $ref: '#/components/responses/SendLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /saved-articles: parameters: - $ref: '#/components/parameters/CommuneVersion' get: operationId: listSavedArticles summary: List the articles this account saved description: 'This account''s reading list: the articles this person put aside to come back to, most recently saved first. It is private to them, and no operation in this API reads somebody else''s. A save is never reported to the newsletter that published the article. Reads and likes are, as engagement records and as the `article.read` and `article.liked` topics; a save has no topic, no tally on `Article.stats`, and no engagement event type. **Needs `account: read`**, which either an API key or an OAuth token can carry. The two rules that gate every article this API serves gate this page too, applied against the person rather than against a newsletter. An article dated in the future is not returned until that moment passes. An article stamped with an audience is returned only while this person is entitled to it: the newsletter''s owner, one of its admins or editors, or a subscriber holding one of the article''s tags. Leave the segment an article was addressed to and it leaves this page. So a page can be shorter than the number of saves the person made, and an entry can disappear without the person having removed it. Neither is an error and nothing in the response marks it. `article` is a reference, and `?expand=article` replaces it with an `ArticleSummary`: title, preview line, cover and publication date, without the body. **Expanding cannot widen the page.** The two rules above are applied again when the article is resolved, so an entry the person may no longer read, or one whose article has been deleted, keeps its bare reference. Treat a reference that stayed a reference as "no detail available", never as an error. `GET /liked-articles` is the same page for likes and follows every rule above.' tags: - Articles security: - apiKey: [] - oauth2: - account:read parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Expand' - $ref: '#/components/parameters/Fields' responses: '200': description: A page of saved articles, most recently saved first. content: application/json: schema: allOf: - $ref: '#/components/schemas/ListEnvelope' - type: object properties: data: type: array description: 'The articles this account put aside to read later, most recently saved first. An entry is present only while this person may still read the article it names, so a page can be shorter than the number of saves they made and an entry can disappear without them having removed it. Neither is an error and nothing in the response marks it. Each `article` is a reference unless `?expand=article` asks for the summary, and an article this person may no longer read keeps its reference either way. ' items: $ref: '#/components/schemas/SavedArticle' examples: twoSaves: summary: Two articles on the reading list, most recently saved first. value: object: list data: - object: saved_article article: object: article id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f created_at: '2026-08-26T10:02:37Z' - object: saved_article article: object: article id: 5b7a1d90-2c34-4e18-9f6b-8d0a1c2b3e4f created_at: '2026-08-19T18:20:04Z' pagination: has_more: false next_cursor: null '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /liked-articles: parameters: - $ref: '#/components/parameters/CommuneVersion' get: operationId: listLikedArticles summary: List the articles this account liked description: 'The articles this person liked, most recently liked first. The same page as `GET /saved-articles`: the same gates, the same `?expand=article` gated the same way, the same silence when an entry is gated away. Read that operation for every rule this one also obeys. **A like is not a save.** A save is a private list a person keeps; a like is a signal they gave the newsletter, and the newsletter can read it. Do not treat the two as interchangeable. **This is the reader''s half of a like, not the newsletter''s.** The tally is `article.stats.likes`, which any credential reads. Who is behind it is readable by the newsletter the like was aimed at, as engagement records on `GET /newsletters/{newsletter}/events?event_type=like` and as the `article.liked` topic. This operation is scoped to a person instead: one person''s own likes across every newsletter they read. **Needs `account: read`**, which either an API key or an OAuth token can carry.' tags: - Articles security: - apiKey: [] - oauth2: - account:read parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Expand' - $ref: '#/components/parameters/Fields' responses: '200': description: A page of liked articles, most recently liked first. content: application/json: schema: allOf: - $ref: '#/components/schemas/ListEnvelope' - type: object properties: data: type: array description: 'The articles this account liked, most recently liked first. Same gates as the saved list: an entry is present only while this person may still read the article, so a page can be shorter than the number of likes they left and an entry can disappear on its own. This is one person''s own likes across every newsletter they read, never a roster of who liked one article. Each `article` is a reference unless `?expand=article` asks for the summary. ' items: $ref: '#/components/schemas/LikedArticle' examples: twoLikes: summary: Two liked articles, most recently liked first, each a bare reference. value: object: list data: - object: liked_article article: object: article id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f created_at: '2026-08-26T09:58:12Z' - object: liked_article article: object: article id: 5b7a1d90-2c34-4e18-9f6b-8d0a1c2b3e4f created_at: '2026-08-19T09:47:51Z' pagination: has_more: false next_cursor: null '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' components: parameters: IdempotencyKey: name: Idempotency-Key in: header required: true description: 'A value of your choosing naming the change this request is making. Send the same value again to retry the same request. Commune replays the answer the first attempt gave instead of making the change twice, and marks the replay with an `Idempotent-Replay: true` response header. Send a different value for a different change: a key reused for a request that differs in any way answers `409`, because replaying an answer to a question you did not ask is a wrong answer you could not detect. A UUID per change is the usual choice. Remembered for 24 hours, per credential, so two credentials choosing the same value never see each other''s answers. Required, not optional. ' schema: type: string minLength: 1 maxLength: 255 examples: uuid: summary: A UUID per change value: 3f7c1a26-9b0e-4f5a-9a2c-2c8f1d6b4e77 ArticlePath: name: article in: path required: true description: 'The article''s `id` (a UUID) or its `short_id`, an eight character base62 string that is unique across Commune. The `slug` is not accepted here because it is unique only within a newsletter. ' schema: type: string examples: byShortId: summary: By short id value: k7Rm2xQp Limit: name: limit in: query required: false description: 'How many items to return in this page. This is a page size, not an offset. Fewer items than requested may come back and that does not mean the collection is exhausted, only an absent `next_cursor` does. ' 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. Unexpanded relationships are returned as a reference object carrying only `id` and `object`. Each operation documents the paths it accepts, and an unknown path answers `400`. Nested paths use a dot, for example `article.newsletter`. ' 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 object, so a client can trim a response it does not need in full. `id` and `object` are always returned. An unknown property name answers `400`. Properties omitted by an operation, such as `content` on any article list, cannot be brought back with `fields`. ' schema: type: string examples: trimmed: summary: Only the fields a link list needs value: title,slug,posted_at Cursor: name: cursor in: query required: false description: 'The `pagination.next_cursor` value from the previous page. Omit it to read the first page. A cursor is opaque, is only valid for the same operation with the same filters, and is not a durable identifier. ' schema: type: string maxLength: 512 CommuneVersion: name: Commune-Version in: header required: false description: 'The contract version this request is written against. Every version published so far is a release date (`YYYY-MM-DD`), which is why the examples look like one, but the value is an opaque identifier: match it against the versions this API publishes rather than parsing it, because a future one may not be only a date. An unknown value answers `400` with `invalid_version`. Omitting the header pins the request to the version that was current when the API key was issued, so an integration keeps working when a newer version ships. ' schema: type: string minLength: 1 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 across Commune and is the identifier its public web profile uses, so it is the one to hardcode in an integration. ' schema: type: string examples: byId: summary: By UUID value: 9a4c1c6e-0f2b-4f47-9d3f-6d1b1a2c3d4e byHandle: summary: By handle value: the-weekly schemas: Error: type: object title: Error description: 'The error envelope. Every non `2xx` response from every operation has this shape, so a client can branch on `error.code` without knowing which operation produced it. ' 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 for a developer reading a log, not for an end user. Do not branch on it, branch on `code`. ' examples: - Newsletter not found. param: type: string description: 'The query, path or body parameter the error is attributed to, when the error is attributable to exactly one. Absent otherwise. ' examples: - cursor allowed_values: type: array description: 'Everything `param` would have accepted, when what it accepts is a finite set. Absent when it is not: a cursor, an identifier or a numeric range has nothing to enumerate, and an empty array would read as "nothing is allowed". It repeats what `message` says in prose, so a caller can correct a request from this one response: the array is what a program branches on, the sentence is what a person or a model reads. On an unknown parameter name rather than an unknown value, this carries the parameter names the operation does accept, since that is the set the caller has to pick from. On an `insufficient_scope` failure there is usually no parameter at fault and `param` is absent, and this carries the one permission that was needed, written the way the permission table writes it, such as `content: read`. The exception is a credential that may call the operation but not with one value of a parameter, such as `?expand=subscriber` on `listNewsletterInsights` without `audience: read`: then `param` names the parameter and this carries the values this credential may send instead. ' items: type: string examples: - - subscribed - unsubscribed - bounced - complained - pending request_id: type: string description: 'Identifier for this request, echoed in the `Commune-Request-Id` response header. Quote it in support requests. ' examples: - req_01j9c8h1q7m3n4p5r6s7t8u9v0 docs_url: type: string format: uri description: 'Link to the documentation for this error code: always `https://usecommune.dev/errors/` followed by the code, a page on what the code means, what usually causes it and how to fix it. ' examples: - https://usecommune.dev/errors/not_found 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 `image/png`. Null for an attachment old enough that none was recorded. ' thumbnail: type: - string - 'null' format: uri description: A smaller rendition, when one was generated. ArticleSummary: type: object title: ArticleSummary description: 'An article as anybody sees it, without its body: what expanding the `article` on a save or a like returns. **The expansion is gated exactly as the collection is.** The two rules that decide whether an entry appears at all, that an article dated in the future is not served and that an article stamped with an audience is served only to the newsletter''s team and to subscribers holding one of its tags, are applied again when the article is resolved. An entry the person may no longer read keeps its `Ref`, so expanding a page can fill it in but can never lengthen it. **A different schema from `Article`, not a trimmed one.** `Article` carries a lifecycle a reader has no part in: what state the dispatch is in, when a queued article may go out, whether it was imported. The body is absent too, as it would be on any list. `object` is `article_summary` rather than `article`, so a client routing on `object` never has to guess whether a missing property was withheld or unset. ' additionalProperties: false required: - object - id - short_id - slug - newsletter properties: object: type: string const: article_summary description: Always `article_summary`. id: type: string format: uuid description: 'Stable identifier, the same value `Article.id` and a `Ref` to this article carry. ' short_id: type: string description: 'Eight character base62 identifier, unique across Commune and safe in a URL. ' examples: - k7Rm2xQp slug: type: string description: 'URL segment under the newsletter. With the newsletter''s handle it makes the permalink, `/n/{handle}/a/{slug}`. ' newsletter: $ref: '#/components/schemas/Ref' description: 'The newsletter that published it, always as a reference. There is no nested `?expand=article.newsletter`. Expand `newsletter` on `GET /memberships` or `GET /subscriptions` to put names to these identifiers. ' title: type: - string - 'null' description: Subject line of the article. preview_text: type: - string - 'null' description: 'The short line email clients show after the subject, and what Commune uses as the excerpt on a card. ' image_url: type: - string - 'null' format: uri description: Cover image. external_url: type: - string - 'null' format: uri description: 'The article''s canonical URL on the newsletter''s own provider, for an imported article. Null for one written in Commune. ' posted_at: type: - string - 'null' format: date-time description: 'When the article went out. Never ahead of now in a response: an article dated in the future is not resolved at all. ' ArticleBodyMarkdown: type: string title: ArticleBodyMarkdown description: 'An article''s text, as Markdown. Create an article documents the Markdown Commune accepts. ' examples: - '# What we learned in March Three things, and the **third** one surprised us. [Unsubscribe]({{ unsubscribe_url }}) | {{ address }} ' ArticleWithContent: title: ArticleWithContent description: 'An article including its rendered body. Returned only by `GET /articles/{article}`. `content` cannot be requested on any list, including through `?fields=`. ' allOf: - $ref: '#/components/schemas/Article' - type: object required: - content properties: content: type: string description: 'The body as HTML. For an article written in Commune this is the email rendered for the web, with personalization placeholders resolved against an empty context so no raw merge tag is ever served. For an imported article it is what the provider published. Treat it as untrusted markup from a third party and render it in a sandboxed context. ' content_markdown: type: - string - 'null' description: 'The same body as Markdown, present only when `content` is named in `?expand=`. It is what a model should read: the HTML is mostly markup it will not use, and one article body can fill a context window on its own. It is a conversion of the body rather than of the HTML above. For an article written in Commune it comes from the document the author wrote, so a code block keeps its language and a table that declares a header becomes a Markdown table. For an imported article it comes from the provider''s HTML. Either way the words, the links, the images, the lists, the code and the quotes survive, and everything presentational does not. `null` means Commune holds no body it can convert faithfully. That happens when the only body it stored is a rendered email, whose words cannot be told apart from its layout. An empty string means the article has no body, which is different. No merge tag ever appears here, resolved or not. ' unevaluatedProperties: false User: type: object title: User description: 'A person''s public profile, and the whole of what this API returns about anybody other than the credential''s own owner. Email address, theme, notification preferences, push subscriptions, read state and saved articles are never carried. ' 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 for an account that has not finished signing up. ' 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 person never set one, so this is rarely null in practice. ' Article: type: object title: Article description: 'One article of a newsletter, without its body. Every collection of articles returns this shape. `GET /articles/{article}` returns `ArticleWithContent`, which is this plus `content`. ' 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 URL and accepted anywhere `{article}` is. ' examples: - k7Rm2xQp slug: type: string description: 'URL segment under the newsletter, unique within it but not across Commune. The permalink is `/n/{handle}/a/{slug}`. Falls back to the `short_id` for an untitled article. ' newsletter: description: 'The newsletter this article belongs to. A `Ref` unless `newsletter` is named in `?expand=`. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Newsletter' title: type: - string - 'null' description: Subject line of the article. Null for an untitled draft. preview_text: type: - string - 'null' description: 'The short line email clients show after the subject, and what Commune uses as the excerpt on a card. ' image_url: type: - string - 'null' format: uri description: 'Cover image. When the creator set none, Commune stamps the first image in the body at send time, so this is usually populated for a sent article. ' external_url: type: - string - 'null' format: uri description: 'The article''s canonical URL on the newsletter''s own provider, for an imported article. Null for one written in Commune. ' status: $ref: '#/components/schemas/ArticleStatus' is_imported: type: boolean description: '`true` when the article came in from the newsletter''s provider, `false` when it was written and sent in Commune. ' posted_at: type: - string - 'null' format: date-time description: 'When the article went out. An article dated in the future is not returned by any read operation until that moment passes, so this is never ahead of now in a response. ' scheduled_for: type: - string - 'null' format: date-time description: 'When a queued article may go out. Set while `status` is `scheduled` and null otherwise. This is not `posted_at` and the difference matters: a queued article has no publication date yet, which is why it stays invisible on every reader surface until it really goes out. Commune dispatches in passes, so this is the moment from which the article may go rather than the moment it will. ' authors: type: array description: 'The byline, in order. Each entry is a `Ref` unless `authors` is named in `?expand=`. Empty when no Commune account is credited. ' items: anyOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/User' thread: description: 'The chat thread this article opened, where its discussion lives. `null` when the newsletter does not open a thread per article. A `Ref` unless `thread` is named in `?expand=`. ' oneOf: - type: 'null' - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Thread' stats: $ref: '#/components/schemas/ArticleStats' created_at: type: string format: date-time description: When the row was created in Commune. updated_at: type: string format: date-time description: When the article was last edited. Esp: type: string title: Esp description: 'Where a newsletter is published from. `commune` means Commune itself sends the email. Every other value is an email service provider whose posts Commune imports. `rss` covers any feed that is not one of the named providers. ' enum: - commune - beehiiv - buttondown - ghost - kit - mailchimp - mailerlite - rss - substack Pagination: type: object title: Pagination description: 'Cursor pagination state. Commune never exposes an offset or a page number: a collection is a moving window, and an offset silently skips or repeats items when the window shifts between two requests. ' additionalProperties: false required: - has_more - next_cursor properties: has_more: type: boolean description: 'Whether another page exists. When `false`, `next_cursor` is `null`. ' next_cursor: type: - string - 'null' description: 'Pass this back as `?cursor=` to read the next page. `null` on the last page. Opaque, and valid only for the same operation with the same filters. ' examples: - Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4 ArticleImageRequest: type: object title: ArticleImageRequest description: 'Exactly one of `source_url` or `content_type`. ' additionalProperties: false properties: source_url: type: string format: uri maxLength: 2000 description: 'A public http or https URL of the image for Commune to download and store. ' examples: - https://example.com/chart.png content_type: type: string enum: - image/jpeg - image/png - image/webp - image/gif description: 'The type of the file you will upload yourself. The answer carries the upload URL. ' examples: - source_url: https://example.com/chart.png - content_type: image/png Newsletter: type: object title: Newsletter description: 'A newsletter and its public profile. Nothing operational is exposed: ESP credentials, OAuth tokens, group and audience ids, feed polling state and language detection bookkeeping all stay server side. ' 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/{handle}` and is accepted anywhere `{newsletter}` is. ' 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 format it. Treat it as untrusted markup and render it in a sandboxed context. ' esp: $ref: '#/components/schemas/Esp' image_url: type: - string - 'null' format: uri description: Square avatar for the newsletter. website_url: type: - string - 'null' format: uri description: The creator's own site, if they linked one. social_links: $ref: '#/components/schemas/SocialLinks' language: type: - string - 'null' description: 'Best known language of the newsletter''s writing as a BCP 47 tag. Detected from recent articles rather than declared, so treat it as a hint. Null before enough has been published to tell. ' examples: - en chat_create_permission: type: string enum: - editors - subscribers - anyone description: 'Who may start a new chat thread in this community. ' allow_non_subscriber_chat: type: boolean description: 'Whether people who have not subscribed may reply in existing threads. ' owner: description: 'The account that owns the newsletter. A `Ref` unless `owner` is named in `?expand=`. ' 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` when none is pinned. A `Ref` unless `featured_article` is named in `?expand=`. ' oneOf: - type: 'null' - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Article' created_at: type: string format: date-time description: When the newsletter was connected to or created on Commune. updated_at: type: string format: date-time description: When the profile last changed. ArticleStatus: type: string title: ArticleStatus description: 'Where an article is in its life. A credential holding only `read` permissions ever sees `sent` and nothing else. An imported article is always `sent`, since Commune sees it after the provider delivered it. ' enum: - draft - scheduled - sending - sent - failed - archived TestSend: type: object title: TestSend description: 'What one test send attempted and what the sending provider accepted. Nothing about the article changed and nobody on the list was touched. ' additionalProperties: false required: - object - article - recipients - sent_to_owner - sent - failed properties: object: type: string const: test_send description: Always `test_send`. article: description: 'The article a copy of which was sent. Always a `Ref`: a test changes nothing about the article, so there is nothing on it to read back. ' $ref: '#/components/schemas/Ref' recipients: type: array description: 'Where the test went, deduplicated and in the order given. These are the addresses sent in the request; Commune adds none and reveals none, so this is empty when the copy went to the credential''s owner. ' items: type: string format: email sent_to_owner: type: boolean description: 'True when the request named no addresses and the copy went to the person the credential belongs to, whose address is not echoed. ' sent: type: integer minimum: 0 description: How many of them the sending provider accepted. failed: type: integer minimum: 0 description: 'How many it refused. `sent` plus `failed` is always the number of `recipients`, or one when `sent_to_owner` is true. A test where every address failed answers `502` rather than this shape. ' Thread: type: object title: Thread description: 'A conversation in a newsletter''s community, together with the message that opened it. Its replies are a separate collection. ' 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/{handle}/chat/{short_id}`. Null for a thread Commune opened under an article, which is reached through the article instead. ' newsletter: description: 'The community this thread lives in. A `Ref` unless `newsletter` is named in `?expand=`. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Newsletter' author: description: 'Who opened the thread. A `Ref` unless `author` is named in `?expand=`. ' anyOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/User' content: type: string description: 'The opening message. HTML, since people format what they write. Treat it as untrusted markup and render it in a sandboxed context. ' 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 a person starting it. These are kept off the global feed, because the article card already represents the conversation there. ' article: description: 'The article that opened this thread, when `is_article_thread` is `true`. `null` otherwise. A `Ref` unless `article` is named in `?expand=`. ' oneOf: - type: 'null' - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Article' reply_count: type: integer minimum: 0 description: Undeleted replies in the thread, at any depth. view_count: type: integer minimum: 0 description: How many times the thread was opened. created_at: type: string format: date-time description: When the thread was opened. updated_at: type: string format: date-time description: When the thread row last changed for any reason. edited_at: type: - string - 'null' format: date-time description: 'When the author last edited the opening message. Null when it was never edited, which is what drives the edited marker in the product. ' last_activity_at: type: string format: date-time description: 'When the thread last received a reply, or when it was opened if it never did. This is the sort key for the thread list. ' ArticleImageUpload: type: object title: ArticleImageUpload description: 'A one-time upload. Send the file''s bytes as the request body, with these headers, before it expires; for example `curl -X PUT -H "Content-Type: image/png" --data-binary @chart.png ""`. ' additionalProperties: false required: - url - method - headers - max_bytes - expires_at properties: url: type: string format: uri description: Where to send the file. It works once. method: type: string const: PUT description: Always `PUT`. headers: type: object description: The headers the upload has to carry. additionalProperties: type: string max_bytes: type: integer description: The largest file the upload accepts. expires_at: type: string format: date-time description: When the upload URL stops working. SocialLinks: type: object title: SocialLinks description: 'The creator''s other homes on the internet, stored as canonical profile URLs. Every key is optional and a newsletter that set none returns an empty object. ' 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. SendRequest: type: object title: SendRequest description: 'Options for a send. Every field is optional, and sending no body at all is the ordinary case. ' additionalProperties: false properties: acknowledge_broken_images: type: boolean default: false description: 'Send even though an image in the body will not load for a reader. Without this, an image Commune could definitively not fetch refuses the send. With it, the send proceeds and the acknowledgement is recorded against the article as it currently reads, so the dispatch that happens a few minutes later honours the same decision. Any edit to the body clears it, which keeps it scoped to the images that were actually looked at. It does not suppress anything else. An unreadable image is still reported on the article, and the footer gate is not escapable at all. ' SavedArticle: type: object title: SavedArticle description: 'One article this account put aside to read later. It carries no identifier of its own, and that is the shape rather than an omission: a save has no identity apart from the pair of the person who made it and the article it points at, and the person is the credential that reads it. There is nothing to address it by, and no operation that would take one. An entry is present only while the person may still read the article it names, so a page of these is a live view rather than a log of what was ever saved. ' additionalProperties: false required: - object - article properties: object: type: string const: saved_article description: Always `saved_article`. article: description: 'The article that was saved. A `Ref` unless `article` is named in `?expand=`, and then an `ArticleSummary`: the title, the preview line, the cover and the publication date, without the body. Expanding it can never widen the page. The same two rules that decide which saves appear at all are applied again when the article is resolved, so an entry whose article this person may no longer read stays a `Ref`. A `Ref` is also what a caller gets for an article that has since been deleted, so treat the reference as "no detail available" rather than as an error. ' anyOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/ArticleSummary' created_at: type: - string - 'null' format: date-time description: When the person saved it. TestSendRequest: type: object title: TestSendRequest description: 'Where a test copy of an article goes. Empty, or no body at all, sends it to the person the credential belongs to. ' additionalProperties: false properties: to: type: array description: 'The addresses to send the test to. Leave it out to send it to yourself. A repeated address is sent to once. Five at most, because this is a look before you send rather than a way to reach a group. ' minItems: 1 maxItems: 5 items: type: string format: email examples: - - editor@example.com - proofreader@example.com ArticleImage: type: object title: ArticleImage description: 'An image stored for an article, at a URL that does not change. The article does not show it until its URL is in the body or `image_url`. ' additionalProperties: false required: - object - url - content_type - size_bytes - source_url - upload properties: object: type: string const: article_image description: Always `article_image`. url: type: string format: uri description: 'Where the image is served. Use it in the article''s Markdown or as its `image_url`. For an upload, it answers `404` until the file is uploaded. ' content_type: type: string enum: - image/jpeg - image/png - image/webp - image/gif description: 'The image''s type. For a download, read from the bytes themselves, whatever the source said. ' size_bytes: type: - integer - 'null' minimum: 1 description: 'How large the stored image is. Null for an upload, whose size is not known until the file arrives. ' source_url: type: - string - 'null' description: The URL the image was downloaded from. Null for an upload. upload: description: 'Where to send the file, when the request named a `content_type`. Null when the image was downloaded. ' oneOf: - $ref: '#/components/schemas/ArticleImageUpload' - type: 'null' ArticleStats: type: object title: ArticleStats description: 'Engagement counts for an article, computed at read time. These are Commune side counts, not provider side email metrics: opens, clicks and deliveries are not here. ' 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 comments store: an article''s discussion is a thread like any other, so this counts the undeleted replies hanging off it. `0` when the article has no thread. ' highlights: type: integer minimum: 0 description: How many passages readers highlighted. ArticleUpdateRequest: type: object title: ArticleUpdateRequest description: 'The properties of an article to change. Send at least one; an empty object answers `400` rather than doing nothing. **Absent and null are different.** A property you leave out is left alone. A property you send as `null` is cleared. An empty string is the same as null, because a title that is present and empty reads as a missing one everywhere it is shown. `slug` is the exception: it can be changed but not cleared, since every article has to have one. This never moves the article''s state and never changes its byline. The state moves through `POST /articles/{article}/schedule`, `/unschedule` and `/send`; the byline is not in this contract. ' additionalProperties: false minProperties: 1 properties: title: type: - string - 'null' maxLength: 300 description: The subject line. Null clears it. preview_text: type: - string - 'null' maxLength: 500 description: 'The short line email clients show after the subject. Null clears it. ' image_url: type: - string - 'null' format: uri maxLength: 2000 description: The cover image. Null clears it. slug: type: string maxLength: 60 pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$ description: 'The article''s URL segment. Changing it moves the article''s permalink, and nothing forwards the old one, so change it knowing that. It is not re-derived when you change the title. A slug that already belongs to another article of this newsletter answers `422`. ' content_markdown: $ref: '#/components/schemas/ArticleBodyMarkdown' examples: - title: What we learned in March, revisited - image_url: null ErrorCode: type: string title: ErrorCode description: 'The stable, machine readable reason a request failed. New codes may be added in a minor version, so treat an unrecognised code as a generic failure of its HTTP status class. Two of these share a status with a neighbour and exist because what a caller does next is different. `invalid_version` is a `400` that is never fixed by changing the request body. `not_commune_newsletter` is a `422` that is never fixed by changing the request at all: it says the newsletter''s articles are published somewhere else and mirrored into Commune afterwards, so Commune cannot write one. Its page at `https://usecommune.dev/errors/not_commune_newsletter`, like every code''s, is its `docs_url`, and it covers moving a newsletter onto Commune''s own publishing, which is the only thing that resolves it. ' enum: - bad_request - invalid_version - unauthorized - forbidden - insufficient_scope - payment_required - not_found - conflict - unprocessable - not_commune_newsletter - rate_limited - internal_error - service_unavailable ArticleCreateRequest: type: object title: ArticleCreateRequest description: 'A new article. Every property is optional: `{}` creates an empty untitled draft. What is created is always a **draft**. There is no `status` here, no `posted_at` and no `scheduled_for`: an article reaches anybody through Schedule an article (`POST /articles/{article}/schedule`) or Send an article to the list (`POST /articles/{article}/send`), both of which run the gates that keep a non-compliant article out of an inbox. ' additionalProperties: false properties: title: type: - string - 'null' maxLength: 300 description: 'The subject line. Null or absent leaves the article untitled, which is legal: a draft is often started before it is named. ' preview_text: type: - string - 'null' maxLength: 500 description: 'The short line email clients show after the subject. ' image_url: type: - string - 'null' format: uri maxLength: 2000 description: 'The cover image. Leave it out and Commune stamps the first image in the body when the article is sent. ' slug: type: string maxLength: 60 pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$ description: 'The article''s URL segment, unique within the newsletter. Lowercase letters, digits and single hyphens. Leave it out and Commune derives one from the title, adding a numeric suffix if that one is taken. An untitled article falls back to its `short_id`. A slug you choose yourself is never renamed for you: one that is already taken answers `422` rather than quietly becoming something else, because a permalink you did not choose is worse than an error you can act on. ' content_markdown: $ref: '#/components/schemas/ArticleBodyMarkdown' examples: - title: What we learned in March preview_text: The third one surprised us. content_markdown: '# What we learned in March Three things, and the **third** one surprised us. [Unsubscribe]({{ unsubscribe_url }}) | {{ address }} ' ThreadVisibility: type: string title: ThreadVisibility description: 'Where a thread is placed. `public` puts it on the global Commune feed and makes it readable by anyone. `subscribers` keeps it inside the newsletter. `paid` narrows it further to the paying part of the audience. Set and changed by the newsletter''s team. ' enum: - public - subscribers - paid ScheduleRequest: type: object title: ScheduleRequest description: 'When an article should go out. ' additionalProperties: false required: - scheduled_for properties: scheduled_for: type: string format: date-time description: 'The moment from which the article may go out. Has to be in the future. Commune dispatches queued articles in passes, so this is the earliest it will go rather than the exact moment it does. ' examples: - '2026-10-01T09:00:00Z' acknowledge_broken_images: type: boolean default: false description: 'Schedule even though an image in the body will not load. See the same field on the send operation for what this acknowledges and how long it lasts. ' LikedArticle: type: object title: LikedArticle description: 'One article this account liked. Property for property the same shape as `SavedArticle`, and a separate schema so a client routing on `object` can tell the two apart. **What they say differs.** A save is a list the person keeps for themselves, and nothing else in this API reveals one. A like is a signal the person gave a newsletter: its tally is public on `Article.stats`, and the newsletter it was aimed at reads who left it on its engagement stream and is pushed it as `article.liked`. Neither gesture is assembled across the newsletters a person reads anywhere but here. Carries no identifier of its own. A like has no identity apart from the person who left it and the article it points at. ' additionalProperties: false required: - object - article properties: object: type: string const: liked_article description: Always `liked_article`. article: description: 'The article that was liked. A `Ref` unless `article` is named in `?expand=`, and then an `ArticleSummary`, under exactly the rules `SavedArticle.article` sets out: the expansion is gated the same way the page is, so it can never reveal an article the page withheld. ' anyOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/ArticleSummary' created_at: type: - string - 'null' format: date-time description: When the person liked it. ListEnvelope: type: object title: ListEnvelope description: 'The envelope every collection is returned in. `data` holds the page, `pagination` holds the cursor state. `data` is required here and typed by each list operation, as an array of the one thing that operation returns, so the item type is stated on the page you are reading. ' required: - object - data - pagination properties: object: type: string const: list description: Always `list`, so a response is self describing. pagination: $ref: '#/components/schemas/Pagination' Ref: type: object title: Ref description: 'An unexpanded relationship. Ask for the relationship in `?expand=` to get the full object in its place. ' 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 schema declares. Most are UUIDs; a `Ref` whose `object` is `user` carries an account identifier, which is an opaque string and not a UUID. Compare it for equality and pass it back; do not parse it. ' examples: - 9a4c1c6e-0f2b-4f47-9d3f-6d1b1a2c3d4e responses: Unauthorized: description: 'No credential was presented, or it is malformed, unknown, revoked or expired, or it is an access token minted for a different audience. Every one of these answers identically, down to the wording and the headers, so a refusal never confirms that a string was once real. ' headers: WWW-Authenticate: description: 'The authentication scheme this API accepts, and where to find out how to get a credential for it. Always `Bearer realm="Commune API", resource_metadata="https://api.usecommune.com/.well-known/oauth-protected-resource"`. `resource_metadata` is the RFC 9728 pointer to this API''s protected resource metadata, which names the authorization server an OAuth client should send its user to. A client holding an API key can ignore it. The header carries no `error` parameter, not even `error="invalid_token"`, because it describes what this API accepts rather than what was wrong with the credential sent, and the reasons above are deliberately indistinguishable. There is no second scheme and no query-parameter fallback, because a credential that can travel in a URL ends up in access logs and referer headers. ' schema: type: string content: application/json: schema: $ref: '#/components/schemas/Error' PaymentRequired: description: 'The credential is allowed to do this but the newsletter''s plan does not include it. Two surfaces can answer it: **insights**, the engagement and metrics operations, which are the only reads Commune reserves the right to meter, and **writing**, every operation that changes something. Every other read stays free on every plan, so a credential refused at one of these can still read everything else. The body names the plan the newsletter is on and the plans that would work. **This status is predictable and should not be how you discover it.** `GET /newsletters/{newsletter}/entitlements` answers the same question in advance, carrying the same plan list this puts in `allowed_values` and the same sentence it puts in `message`. Read it once at the start of a run rather than finding out in the middle of one. ' content: application/json: schema: $ref: '#/components/schemas/Error' SendLimited: description: 'Too many requests. Back off and retry after the interval named by the `Retry-After` response header. Usually one of the budgets in `RateLimit-Policy` ran out, and the `RateLimit-*` headers on this response say which and when it resets. This operation can also reach a **daily send limit**, which is counted apart from those budgets and is not reported in them: how many times an article may be dispatched to a newsletter''s whole list in a day, or how many test copies a credential may send to addresses it names. Neither counts recipients, so the size of a send is never what refuses it. When one of these is what answered, the message says so by name and `Retry-After` is measured in hours rather than seconds, which is how to tell the two apart. ' headers: Retry-After: description: Seconds to wait before retrying. schema: type: integer minimum: 1 content: application/json: schema: $ref: '#/components/schemas/Error' NotFound: description: 'No such resource, or the key is not allowed to know that it exists. Commune answers `404` rather than `403` where distinguishing the two would leak the existence of private content. ' content: application/json: schema: $ref: '#/components/schemas/Error' RateLimited: description: 'Too many requests. Back off and retry after the interval named by the `Retry-After` response header. One of the budgets in `RateLimit-Policy` ran out, and the `RateLimit-*` headers on this response say which and when it resets. ' headers: Retry-After: description: Seconds to wait before retrying. schema: type: integer minimum: 1 content: application/json: schema: $ref: '#/components/schemas/Error' Forbidden: description: 'The credential is valid but is not allowed to do this. Two codes answer with this status, and `error.code` says which. **`insufficient_scope`: it does not hold the permission.** The operation needs, say, `audience: read` on the newsletter addressed, and this credential holds less than that there. `allowed_values` carries the permission that was needed, and the message says what the credential does hold on that newsletter, because a credential granted the wrong family and a credential belonging to somebody whose standing on the team has narrowed look identical without it. The answer can differ per newsletter: the same credential may be allowed here and refused on the next one it reaches. The same code answers an operation that needs the **account permission** from a credential that does not carry it. That permission is about the person a credential belongs to rather than about any newsletter, so nothing granted on a newsletter adds up to it. It is granted on the credential itself, when a key is minted or when an authorization asks for `account:read`. And it answers a parameter the credential may send, but not with the value it sent: a filter a credential holding only `read` permissions may not use, or an `expand` path whose rows need a permission the operation does not. `param` names the parameter, and `allowed_values` carries what this credential may send instead, or is absent when it may send nothing there at all. **`forbidden`: it may not act here at all.** Either the credential does not reach the newsletter addressed, because it was never granted it or because the person it belongs to can no longer act on it, or it reaches no newsletter at all; `param` is `newsletter`, and `GET /newsletters` lists the ones it does reach. Or, on `DELETE /api-keys/{key}`, the credential named belongs to somebody else. Neither carries `allowed_values`, because there is no value to send instead. ' content: application/json: schema: $ref: '#/components/schemas/Error' ServiceUnavailable: description: 'A capability this operation depends on did not answer. Every other operation is unaffected, so back off on this one rather than on the API. Two parts of the API can answer this, because they are the only ones Commune cannot serve out of its own database. **Event delivery.** Destinations, the attempt log and the portal all live in the delivery service. It is never an empty answer instead, because a destination list or an attempt log that came back empty for this reason reads exactly like a newsletter that has registered no endpoints and sent nothing anywhere. **`sendArticleTest`.** A test copy is sent while the request is open, by Commune''s sending service, and this answers when that service could not be reached or when the sending provider refused every address on the test, so nothing arrived. Nothing about the article changes either way, and the message says which of the two happened. ' headers: Retry-After: description: 'Seconds to wait before retrying. Absent in the one case that will not pass on its own, a deployment where event delivery is not available at all; the message says so, and retrying will not clear it. ' schema: type: integer minimum: 1 content: application/json: schema: $ref: '#/components/schemas/Error' Conflict: description: 'The request collided with something. On a write this is always the `Idempotency-Key`, in one of two ways, and the message says which. Either the key was already used for a **different** request, which is refused rather than answered with the earlier request''s result. Or an earlier request using the same key has not finished, or never reported an outcome, in which case this one was not run and the key becomes usable again shortly. Nothing was changed by a request that answers this. ' headers: Retry-After: description: 'Seconds to wait before retrying, on the second case only. ' schema: type: integer minimum: 1 content: application/json: schema: $ref: '#/components/schemas/Error' Unprocessable: description: 'The request is well formed and every value in it is legal, and the state of what it addresses refuses it anyway. The message says what about that state is in the way. ' 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' BadRequest: description: "The request was malformed, and the same request will fail the same way\nuntil it is changed. `param` names the parameter or header at fault\nwhen there is exactly one, and `allowed_values` lists what it accepts\nwhen that is a finite set. The code is `bad_request` for every case\nbelow except the last.\n\n* **A query parameter**: one the operation does not have, a value\n outside its set, range or format (an unparseable cursor, an unknown\n `expand` path or `fields` name, an identifier that is not a UUID),\n or a required one left out, such as `q` on a search or `newsletter`\n when the credential reaches more than one.\n* **The request body**: not JSON, not the shape the operation reads,\n a property it does not write, or a value of the wrong type, length\n or format. `param` is absent here, since the body is not a\n parameter, and the message names the property.\n* **The `Idempotency-Key` header**, on an operation that changes\n something: missing, or a value this API will not store.\n* **An unrecognised `Commune-Version`**, which answers with its own\n code, `invalid_version`, because it is never fixed by changing the\n body.\n" content: application/json: schema: $ref: '#/components/schemas/Error' securitySchemes: oauth2: type: oauth2 description: 'An OAuth access token, sent as `Authorization: Bearer `. The walkthrough of the whole flow is at [usecommune.dev/use-cases/build-an-integration](https://usecommune.dev/use-cases/build-an-integration): discovery, registration, PKCE, the consent screen, the exchange, refresh and revocation. Ask for a family scope and the person picks which newsletter the token reaches; ask for `account:read` alone and it reaches no newsletter and reads only the account it belongs to. Each operation lists the scopes a token must carry to call it. An operation that lists none takes any token. Discover the URLs under `flows` at runtime from `GET /.well-known/oauth-authorization-server` rather than hardcoding them. ' flows: authorizationCode: authorizationUrl: https://usecommune.com/api/oauth/authorize tokenUrl: https://usecommune.com/api/oauth/token refreshUrl: https://usecommune.com/api/oauth/token scopes: content:read: Read articles, threads and the rest of what a newsletter publishes. content:write: Create, edit and delete that content. audience:read: Read subscribers, tags and segments, including email addresses. audience:write: Add, tag and remove subscribers. insights:read: Read engagement, delivery and growth figures. insights:write: Write back an insight the newsletter owns. sending:read: Read sends, schedules and delivery outcomes. sending:write: Send an article, schedule one, and cancel a schedule. settings:read: Read a newsletter's configuration, senders and domains. settings:write: Change that configuration. webhooks:read: Read event destinations and their delivery history. webhooks:write: Create and remove event destinations. account:read: Read the person the credential belongs to, and nothing about any newsletter. apiKey: type: http scheme: bearer bearerFormat: Commune API key description: 'A Commune API key, sent as `Authorization: Bearer `. A key is granted one or more newsletters and carries six permission families on each, every one of them `none`, `read` or `write`. An operation names the family and the level it needs. A key is minted by a creator in Commune''s settings: no flow, no consent screen, no expiry. That is the whole difference from `oauth2`. An operation that declares both accepts either credential, and what each may do is what it was granted. ' x-refined-from: - usecommune-openapi.json - usecommune-openapi.yml x-deferred: - resource: user_newsletters scope: partner reason: The newsletters a person owns or is on the team of. Public one profile at a time, but served in bulk it maps the network. A Partner API candidate. - resource: user_subscriptions scope: partner reason: The newsletters a person subscribes to. The reader side of the same network graph, so it waits for a Partner API with it. - resource: user_activity scope: public reason: The threads, highlights and articles sub resources of a public profile. Each filters a collection that has its own operation. - resource: user_settings scope: reader reason: Theme, contrast and the rest of a person's account preferences. Personal, and of no use to an integration. - resource: notification_preferences scope: reader reason: Personal account settings. - resource: push_subscriptions scope: reader reason: Per device push endpoints. Credential shaped, and personal. - resource: newsletter_settings scope: creator reason: Chat permissions, physical address and editor defaults. Split from the core object so the public schema stays frozen, and deferred with the write surface it exists to serve. - resource: invitations scope: creator reason: Carries invitee email addresses and single use tokens, and is write shaped. This version of the API is reads only. - resource: esp_connections scope: creator reason: Holds provider OAuth tokens and API keys. The connection becomes readable without them; the credentials never do. - resource: esp_imports scope: creator reason: Import and migration runs are long running writes against an outside provider. This version of the API is reads only. - resource: esp_share_audiences scope: creator reason: The provider side allow list that decides what Commune ingests. Import configuration, not a resource an integration reads. - resource: rss_authors scope: creator reason: The feed author to team member mapping. Import configuration, wired to one provider path. - resource: newsletter_exports scope: never reason: An admin only operation, not part of the creator catalog. - resource: article_drafts scope: creator reason: Commune's editor stores its own document format, and pinning it in a public contract would stop the editor evolving. - resource: article_preview scope: creator reason: Renders an article to final email HTML. Worth exposing, and it would pin the merge tag engine and the block system while both are still moving. - resource: article_compliance scope: creator reason: The pre send gate as a readable resource, answering "would this send?" without sending. Its blocker vocabulary is still growing, and freezing it now would freeze the gate; the send and schedule operations report the same refusals when they refuse. - resource: article_move scope: creator reason: Moving a draft from one newsletter to another. A credential reads one newsletter, so both ends of the move cannot be named by one of them. - resource: article_thread scope: public reason: An article's discussion, reachable as a sub resource. It is a thread and has an operation already; a second path to it is navigation. - resource: article_comments scope: public reason: Dead table. An article's discussion is its chat thread, so the count is on `article.stats.comments` and the comments themselves are that thread's messages. - resource: article_saved_event scope: creator reason: 'A topic for an article being saved or unsaved. Built alongside `article.liked` and `article.read` and then withdrawn before it shipped, on the ground that it is not the same kind of change they are. Those two ride a disclosure that already exists. A credential holding `insights: read` reads `GET /newsletters/{newsletter}/events` today, which names which subscriber viewed or liked which article, so a topic carrying the same facts tells a creator nothing they could not already fetch. A save has no counterpart anywhere: no entry in `EngagementEventType`, no tally on `Article.stats`, nothing in the product that shows a creator who saved what, and a row only its owner can read. The topic would therefore have been the first thing ever to tell a creator anything about saves, and the thing it told them would be who. That is a decision about what readers are told is private, not a gap in the catalog, and it is deferred until that decision is made rather than shipped as a side effect of building its two neighbours. `article_saves` itself is untouched: `GET /saved-articles` still returns a person their own list.' - resource: article_shared_event scope: creator reason: 'A topic for an article being shared. Refused rather than queued, because Commune does not observe a share and cannot: the product hands the reader to the operating system''s own share sheet, which reports nothing back, so the only shares that could ever be counted are the ones that begin with a button inside Commune, and even those end somewhere Commune cannot see. Read `share` in `EngagementEventType` as the record of an earlier attempt rather than as a signal that exists. The value is declared, the insight scores weight it, and the collection at `GET /newsletters/{newsletter}/events` will return one if it ever finds one. None of that makes a share observable, and a `share` row is not something any newsletter has. Publishing a topic for it would put a channel in this catalog that can never carry a message, which is worse than an absence: an absence is visible, and a silent channel reads as a quiet week.' - resource: article_read_state scope: reader reason: 'Per reader read and unread state, as a resource a client reads back and writes. Written on every read in the product, so exposing it invites the polling loop `article_views` is deferred for, on the same hot path. The `article.read` topic is not this resource arriving early: it is pushed rather than polled, which is the whole of what the objection was about, it reports one crossing per reader per article rather than a state a client can re-read, and it cannot be written.' - resource: article_views scope: creator reason: 'A write on every read in the product. Exposing it as a readable counter invites polling loops against a hot path. Still deferred after `article.read` landed, and not made redundant by it: that topic deliberately reports neither anonymous reads nor repeat visits, so it is not the counter and a consumer cannot build the counter out of it.' - resource: thread_demotion scope: creator reason: Taking a thread back off the global feed. Promoting one is an operation; the reverse has no topic and no considered answer to what a consumer already told about it should do. - resource: article_schedule_cancelled_event scope: creator reason: A topic for a cancelled schedule. Cancelling is an operation; the event is not, for the reason directly above, and the article's own status is the authority until there is an answer. - resource: thread_read_state scope: reader reason: Per user last read timestamps and mutes. A user token could hold it; a row names a thread, and handing one back would let an app walk into a private thread whose other participants consented to nothing. - resource: thread_participants scope: public reason: Who spoke in a thread. Derivable from the thread's messages, which have an operation of their own. - resource: moderation scope: creator reason: No moderation queue exists yet. An auditable log is worth having before write access rather than after it. - resource: posts scope: public reason: Retired. Posts were folded into newsletter scoped chat threads, so the resource is `threads`, and modelling `posts` would put a dead stack into a contract with outside consumers. - resource: post_replies scope: public reason: Retired with posts. A reply is a `message` in a thread. - resource: reposts scope: public reason: 'Retired with posts, and never wired up: the internal surface returns a hardcoded zero.' - resource: community_member scope: public reason: One person's place in a community, addressable on its own. The person has an operation and the place carries nothing but a date, so a second path to it is navigation rather than a resource. - resource: suppressions scope: creator reason: Bounces, complaints and unsubscribes as one list. The data is spread across two tables and there is no single surface to freeze yet. - resource: audience_count scope: creator reason: Commune's subscriber records are a partial cache of an outside provider's list, so any total derived from them would misstate the audience. Ask the provider. - resource: article_deliveries scope: creator reason: Per recipient send results, including bounces. Deferred until the send pipeline's own shape is stable enough to freeze. - resource: article_send_stats scope: creator reason: Opens and clicks come from the sending provider on the provider's schedule, so a number read here would be stale in a way the contract could not describe. - resource: send_links scope: creator reason: Click breakdown per destination URL. Clicks are recorded as events and never aggregated by destination, so the rollup does not exist. - resource: deliverability scope: creator reason: Rolling bounce and complaint health against thresholds. Derivable, and nothing computes it today. - resource: delivery_retries scope: never reason: Re-sending a send's failed recipients. Commune retries transient failures itself; what still fails is followed up by its team, because some of it may already have been delivered. - resource: network_metrics scope: never reason: Internal analytics, computed on a cron for Commune's own use. - resource: feed scope: public reason: Public threads, articles and highlights unioned into one stream. A discriminated union whose member shapes and ranking are still moving. - resource: newsletter_feed scope: public reason: The same union scoped to one newsletter. Deferred with `feed`. - resource: discover scope: public reason: An editorial surface whose ranking is still being tuned. Freezing its shape now would freeze an experiment. - resource: notifications scope: reader reason: A person's notification inbox. A user token is the right credential for it; every item points at a thread, message or article somewhere, and serving those references needs the subject registry to answer what an app may follow them to. - resource: notification_stream scope: reader reason: The server sent events channel behind a person's notification inbox. Deferred with `notifications` above, and additionally because a stream is not a Path Item. It was described in a separate AsyncAPI document for a while, on the grounds that a stream is not a Path Item, but it was never built and that document was the only thing that document held which this one could not express. Both are gone. A stream that is worth publishing to API keys brings the second document back with it. - resource: reader_digest scope: reader reason: The weekly roundup as data rather than as an email. Personal. - resource: creator_digest scope: creator reason: The creator side weekly. Every number in it is readable from the Metrics operations, so it is a rendering rather than a resource. - resource: billing scope: creator reason: Plan, usage and payment method. A money surface deserves its own contract and its own review, not a corner of the read catalog. - resource: media scope: reader reason: Upload only, and this version of the API is reads only. - resource: render_email scope: creator reason: 'Renders arbitrary editor JSON to email safe HTML. Same reason as `article_preview`: it would pin the block system in a public contract.' - resource: oembed scope: public reason: oEmbed for articles, threads and highlights. A separately published spec with its own discovery rules, not a resource in this one. - resource: api_key_mint scope: never reason: 'Minting a credential, which is refused rather than queued. A key that can mint keys outlives its own revocation: an intruder makes a second one, the creator revokes the first, and nothing they did stopped anything. A key is minted by a signed in person in Commune''s settings, where the secret is shown once. Listing and revoking keys are operations above.' - resource: oauth scope: public reason: 'The authorize and token endpoints a third party app uses. Built, and on the authorization server rather than here: they live in the Commune app, because issuing a credential means showing a signed-in person a screen and this service has no sessions. `GET /.well-known/oauth-protected-resource` is how a client finds them, and the flow is walked through in full at `usecommune.dev/use-cases/build-an-integration`. Not to be confused with `oauth_protected_resource` below: that one is this service saying where tokens for *it* come from.' - resource: oauth_protected_resource scope: public reason: '`GET /.well-known/oauth-protected-resource`, the RFC 9728 metadata document a client fetches after a `401` to find the authorization server. It is not a Commune resource and it is not versioned by `Commune-Version`: its shape is fixed by the RFC, it is the same bytes for every caller, and it is unauthenticated because discovery is what a caller does when it has no usable credential. The authorization server it names is not this API; it is the Commune app itself, where the creator''s session and the consent screen already are.' - resource: spec_documents scope: public reason: This document, served as JSON and as YAML, each with `?version=`, `?profile=` and `?lang=`. Describing itself inside itself is circular. - resource: mcp_server scope: creator reason: '`POST /mcp`, the Model Context Protocol endpoint. It is a JSON-RPC envelope over the operations declared above rather than a resource of its own: one tool is one Arazzo workflow, and every step of every workflow is one of these operations, dispatched through the same gateway with the caller''s own key. Declaring the envelope here would publish a second, untyped way to call operations that are already typed, and OpenAPI cannot describe what a `tools/call` body may contain without restating all twenty argument schemas the manifest already carries.' - resource: webhooks scope: never reason: 'Inbound endpoints for outside services, authenticated by signature rather than by key. The outbound direction is not this resource: the events a consumer receives are the generated `webhooks` block.' - resource: cron scope: never reason: Internal scheduled jobs, guarded by a shared secret. - resource: admin scope: never reason: Commune staff surface. Never public.