openapi: 3.2.0 info: title: Usecommune Metrics API version: '2026-08-26' contact: name: Commune url: https://usecommune.com email: support@usecommune.com description: 'Operations tagged Metrics 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: Metrics description: 'The rolled up numbers for a newsletter and for one article: headline stats for a period, acquisition attribution, bucketed series for charting, and one article''s email performance beside its community response. What a dashboard reads, where Engagement is what an automation reads. Creator scope, and part of the one read surface Commune may put behind a plan.' paths: /newsletters/{newsletter}/stats: parameters: - $ref: '#/components/parameters/CommuneVersion' - $ref: '#/components/parameters/NewsletterPath' get: operationId: getNewsletterStats summary: Retrieve a newsletter's headline numbers description: 'One snapshot of a newsletter over a window: how the audience moved, what was published, how much the community talked, and how the email performed. It answers the question a dashboard opens with, in one call rather than six. Every number is scoped to the window. Pick the window with `period`, or state it exactly with `since` and `until`. Read `audience` and `publishing` carefully before charting them. Commune''s record of a newsletter''s subscribers is complete only for a `commune` newsletter. For one connected to an outside provider it is a partial cache of that provider''s list, which is why the field is called `known_subscribers` and not `subscriber_count`. Do not present it as the newsletter''s audience size, and ask the provider for that number instead. `publishing.sent` counts the articles Commune has a record of and is never a count of emails delivered, which lives in `delivery`. Needs `insights: read`. What a key counts depends on whether it acts for the newsletter, which a key does when it holds `write` in any family. Such a key counts everything, including drafts, scheduled articles and conversations visible only to subscribers. A key that holds only read permissions is a reader of the published newsletter and counts what a reader can see: `publishing.sent` covers published articles that are not restricted to a tag, `publishing.scheduled` is `null`, `community.threads`, `messages` and `reactions` cover public conversations and the replies in them (an article''s own discussion is visible to subscribers only, so it is not among them), and `community.highlights` covers the articles it can read. `audience` and `delivery` are the same for both kinds of key.' tags: - Metrics security: - apiKey: [] - oauth2: - insights:read parameters: - $ref: '#/components/parameters/Expand' - $ref: '#/components/parameters/Fields' - $ref: '#/components/parameters/StatsPeriod' - $ref: '#/components/parameters/StatsSince' - $ref: '#/components/parameters/StatsUntil' responses: '200': description: The newsletter's numbers for the resolved window. content: application/json: schema: $ref: '#/components/schemas/NewsletterStats' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /newsletters/{newsletter}/growth: parameters: - $ref: '#/components/parameters/CommuneVersion' - $ref: '#/components/parameters/NewsletterPath' get: operationId: getNewsletterGrowth summary: Retrieve a newsletter's acquisition breakdown description: 'Where a newsletter''s new subscribers came from over a window: the split by the import that brought them in, and how the invite funnel performed. `by_source` is about imports. It groups on the value an import stamped, so migrating a newsletter between providers later never relabels the history: a value that is already set is never overwritten. For whether somebody subscribed through Commune at all, read `source` on the subscriber, which is a different question with a different answer. The counts here are arrivals Commune recorded inside the window and are not the newsletter''s audience size. For a newsletter connected to an outside provider they also miss anyone who joined at the provider between two imports.' tags: - Metrics security: - apiKey: [] - oauth2: - insights:read parameters: - $ref: '#/components/parameters/Expand' - $ref: '#/components/parameters/Fields' - $ref: '#/components/parameters/StatsPeriod' - $ref: '#/components/parameters/StatsSince' - $ref: '#/components/parameters/StatsUntil' responses: '200': description: The acquisition breakdown for the resolved window. content: application/json: schema: $ref: '#/components/schemas/NewsletterGrowth' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /newsletters/{newsletter}/timeseries: parameters: - $ref: '#/components/parameters/CommuneVersion' - $ref: '#/components/parameters/NewsletterPath' get: operationId: getNewsletterTimeseries summary: Retrieve one newsletter metric bucketed over time description: 'One metric, bucketed, for charting. Every other operation in this domain answers with a snapshot, which is the wrong shape for a line, so this one returns the series behind it. Name the quantity with `metric` and the bucket width with `interval`. Buckets are contiguous, and a bucket in which nothing happened comes back with a `value` of `0` rather than being omitted, so a client can plot the array without filling the gaps itself. `subscribers` counts subscriptions Commune recorded in each bucket, not the running audience size, and it inherits the partial cache caveat that applies wherever Commune counts the subscribers of a newsletter connected to an outside provider. `sends` counts the dispatches Commune performed and is never a count of every email the newsletter has sent, because an imported article was mailed by the provider before Commune saw it. Needs `insights: read`. `threads` and `messages` depend on whether the key acts for the newsletter, which a key does when it holds `write` in any family. Such a key counts every conversation. A key that holds only read permissions counts public conversations and the replies in them, which leaves out an article''s own discussion, visible to subscribers only. The other four metrics are the same for both kinds of key.' tags: - Metrics security: - apiKey: [] - oauth2: - insights:read parameters: - $ref: '#/components/parameters/Expand' - $ref: '#/components/parameters/Fields' - $ref: '#/components/parameters/StatsSince' - $ref: '#/components/parameters/StatsUntil' - name: metric in: query required: true description: Which quantity to bucket. Exactly one per request. schema: $ref: '#/components/schemas/TimeseriesMetric' - name: interval in: query required: false description: 'How wide each bucket is. Defaults to `day`. A week starts on Monday and a month on the first, both in UTC. ' schema: $ref: '#/components/schemas/TimeseriesInterval' responses: '200': description: The requested metric, bucketed over the resolved window. content: application/json: schema: $ref: '#/components/schemas/Timeseries' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /articles/{article}/stats: parameters: - $ref: '#/components/parameters/CommuneVersion' - $ref: '#/components/parameters/ArticlePath' get: operationId: getArticleStats summary: Retrieve one article's performance description: 'How one article did on both sides at once: what the email did, and what the community did with it afterwards. No email provider can return the right hand column, because the reading, highlighting and arguing happened on Commune. `email` is present only for an article Commune sent itself. For an article imported from an outside provider it is `null`, because that provider mailed it and never handed Commune the per recipient outcome. It is `null` for an article that has not been sent yet as well. `community` is computed at read time and keeps counting long after the send, so two reads a week apart legitimately disagree. Needs `insights: read`. The public tallies on an article, `likes`, `comments` and `highlights`, live on the article itself and need only `content: read`. Which articles a key can measure depends on whether it acts for the newsletter, which a key does when it holds `write` in any family. Such a key can measure any of the newsletter''s articles, including a draft or one scheduled for later. A key that holds only read permissions can measure exactly the articles `getArticle` would return to it: sent, already published and not restricted to a tag. Any other article answers `404`, the same answer as an id that does not exist, so this operation never confirms that an unpublished article exists. For an article both kinds of key can read, the numbers are the same.' tags: - Metrics security: - apiKey: [] - oauth2: - insights:read parameters: - $ref: '#/components/parameters/Expand' - $ref: '#/components/parameters/Fields' responses: '200': description: The article's email and community performance. content: application/json: schema: $ref: '#/components/schemas/ArticlePerformance' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' components: 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. 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. 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. ' 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 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. ArticlePerformance: type: object title: ArticlePerformance description: 'One article measured on both sides at once: what the email did, and what the community did with it afterwards. Not to be confused with `ArticleStats`, the small public tally that hangs off an article itself. This is the `insights` report. ' additionalProperties: false required: - object - article - email - community properties: object: type: string const: article_stats description: Always `article_stats`. article: description: 'The article measured. A `Ref` unless `article` is named in `?expand=`. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Article' email: type: - object - 'null' additionalProperties: false description: 'What happened in the inbox, counted per recipient from Commune''s own send records. `null` for an article Commune did not send: one imported from an outside provider, which mailed it without handing Commune the outcome, and one that has not been sent yet. These are counts of recipients, not rates. Divide by `delivered` rather than by `recipients` to get the rates a provider quotes. ' required: - recipients - delivered - opened - clicked - bounced - unsubscribed properties: recipients: type: integer minimum: 0 description: 'Addresses the dispatch was aimed at. For a tag scoped article this is the size of that segment, not of the whole list. ' delivered: type: integer minimum: 0 description: Recipients the provider accepted and delivered to. opened: type: integer minimum: 0 description: 'Recipients who opened at least once, not the number of opens. Undercounts readers whose mail client blocks the tracking pixel and overcounts the ones whose client prefetches it. ' clicked: type: integer minimum: 0 description: 'Recipients who clicked at least one link, not the number of clicks. Which links they clicked is not on this report. ' bounced: type: integer minimum: 0 description: 'Recipients the provider could not deliver to. A hard bounce also suppresses that subscriber for later sends. ' unsubscribed: type: integer minimum: 0 description: 'Recipients who opted out from this article, where the opt out carried enough to attribute it. Best effort: someone who unsubscribed inside the app instead is not counted here. ' community: type: object additionalProperties: false description: 'What happened on Commune. Computed at read time and still moving, so two reads a week apart legitimately disagree. ' required: - views - likes - saves - highlights - thread_messages - participants properties: views: type: integer minimum: 0 description: 'People who opened the article on Commune, counted once each rather than once per visit. ' likes: type: integer minimum: 0 description: People who liked the article. saves: type: integer minimum: 0 description: 'People who put the article in their own reading list. Who they are stays private. ' highlights: type: integer minimum: 0 description: 'Passages readers marked inside the body. The sentences worth reading before writing the next article. ' thread_messages: type: integer minimum: 0 description: 'Replies in the article''s discussion. Commune has no separate comments store: an article''s discussion is a chat thread like any other, so this counts the undeleted replies hanging off it, and it is `0` for an article nobody has discussed. ' participants: type: integer minimum: 0 description: 'Distinct people who replied, so a reader who posted six times counts once. The number that says whether an article started a conversation or an argument between two people. ' 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. ' 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 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. 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. TimeseriesMetric: type: string title: TimeseriesMetric description: 'Which quantity a series buckets. Each value is a count of things that happened inside a bucket, never a running total, so a client that wants a cumulative line adds them up itself. ' enum: - subscribers - sends - opens - clicks - threads - messages 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 TimeseriesInterval: type: string title: TimeseriesInterval description: 'How wide each bucket is. A week starts on Monday and a month on the first, both in UTC, so buckets line up across two newsletters in different time zones. ' enum: - day - week - month 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 NewsletterGrowth: type: object title: NewsletterGrowth description: 'Where a newsletter''s new subscribers came from inside one window. ' additionalProperties: false required: - object - newsletter - period_start - period_end - by_source properties: object: type: string const: newsletter_growth description: Always `newsletter_growth`. newsletter: description: 'The newsletter that grew. A `Ref` unless `newsletter` is named in `?expand=`. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Newsletter' period_start: type: string format: date-time description: Start of the resolved window, inclusive. period_end: type: string format: date-time description: End of the resolved window, exclusive. by_source: type: array description: 'One entry per acquisition source that produced at least one subscriber inside the window, largest first. A source that produced none is omitted rather than returned as a zero. ' items: type: object additionalProperties: false required: - source - known_subscribers properties: source: type: - string - 'null' description: 'Which import brought these people in: the provider''s slug, or `csv` for a file. `null` is the ordinary outcome for anyone who subscribed through Commune rather than arriving in an import, because only an import stamps this. It also covers rows written before Commune recorded anything here, which cannot be attributed after the fact. The two are not told apart, so a null bucket is not a count of Commune arrivals; the subscriber''s own `source` is. Two further values, `commune` and `imported`, appear only on rows labelled once when the field was introduced and are written by nothing today. ' examples: - kit known_subscribers: type: integer minimum: 0 description: 'How many subscriptions Commune recorded from this source inside the window. It counts arrivals Commune saw, so for a newsletter connected to an outside provider it misses anyone who joined at the provider between two imports. ' NewsletterStats: type: object title: NewsletterStats description: 'A newsletter''s headline numbers over one window: audience movement, what was published, community activity and email performance. Nothing here is a running total. Every number describes the resolved window, which the response echoes so a client can label a chart without recomputing what it asked for. ' additionalProperties: false required: - object - newsletter - period_start - period_end - audience - publishing - community - delivery properties: object: type: string const: newsletter_stats description: Always `newsletter_stats`. newsletter: description: 'The newsletter these numbers describe. A `Ref` unless `newsletter` is named in `?expand=`. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Newsletter' period_start: type: string format: date-time description: 'Start of the resolved window, inclusive. Echoed because `period`, `since` and `until` can each decide it. ' period_end: type: string format: date-time description: End of the resolved window, exclusive. audience: type: object additionalProperties: false description: 'How the list moved. Every count here comes from Commune''s own subscriber records, which are the source of truth only for a `commune` newsletter. ' required: - known_subscribers - net_change - by_status properties: known_subscribers: type: integer minimum: 0 description: 'How many subscribers Commune has a record of at `period_end`. Not the newsletter''s subscriber count. For a newsletter connected to an outside provider, Commune''s records are a partial cache of that provider''s list, filled in by imports and by people who joined through Commune, so this is a floor and not a total. Ask the provider for the real number. ' net_change: type: integer description: 'Subscribers gained minus subscribers lost inside the window, against the same partial record. Negative when the list shrank. ' by_status: type: object additionalProperties: false description: 'How the known subscribers split across the subscription states at `period_end`. The keys are the `SubscriberStatus` values. ' required: - subscribed - unsubscribed - bounced - complained - pending properties: subscribed: type: integer minimum: 0 description: Mailable. unsubscribed: type: integer minimum: 0 description: Opted out, and kept so the history survives. bounced: type: integer minimum: 0 description: Undeliverable, and suppressed by the send pipeline. complained: type: integer minimum: 0 description: Reported the email as spam, and suppressed. pending: type: integer minimum: 0 description: Recorded but not yet confirmed as mailable. publishing: type: object additionalProperties: false description: 'Cadence. These are counts of articles, never counts of emails. ' required: - sent - scheduled properties: sent: type: integer minimum: 0 description: 'Articles that went out inside the window and that Commune has a record of. Commune''s record is not exhaustive, so never present this as the number of emails the newsletter sent. That number lives on a send. For a key that holds only read permissions, only the articles it can read: published and not restricted to a tag. ' scheduled: type: - integer - 'null' minimum: 0 description: 'Articles queued to go out after `period_end`. A forward looking count, so it does not move when the window does. `null` for a key that holds only read permissions. Queued articles are unpublished, which such a key may not see, so it is not told how many there are. `null` is not a count of zero: the newsletter may well have articles queued. ' community: type: object additionalProperties: false description: 'What happened in the newsletter''s community inside the window. All four are Commune side counts with no email equivalent. For a key that holds only read permissions, all four cover only what it can read: public conversations and the replies in them, and highlights on articles it can read. An article''s own discussion is visible to subscribers only, so it is not counted for such a key. ' required: - threads - messages - highlights - reactions properties: threads: type: integer minimum: 0 description: 'Conversations started, including the ones Commune opens under a published article. ' messages: type: integer minimum: 0 description: 'Replies posted inside those conversations. An article''s comments are replies in its thread, so they are counted here. ' highlights: type: integer minimum: 0 description: Passages readers marked inside an article. reactions: type: integer minimum: 0 description: Emoji reactions added to a message. delivery: type: - object - 'null' additionalProperties: false description: 'How the email performed across the articles sent inside the window. `null` for a newsletter Commune does not send, because the provider that sent the mail holds those numbers and does not hand them over per article. ' required: - open_rate - click_rate properties: open_rate: type: - number - 'null' minimum: 0 maximum: 1 description: 'Opens over deliveries, as a fraction between `0` and `1`. `null` when nothing was delivered in the window, which is not the same as a rate of zero. Open tracking is unreliable by nature: a mail client that prefetches images inflates it and one that blocks them suppresses it. ' click_rate: type: - number - 'null' minimum: 0 maximum: 1 description: 'Recipients who clicked at least one link, over deliveries, as a fraction between `0` and `1`. `null` when nothing was delivered in the window. ' Timeseries: type: object title: Timeseries description: 'One metric bucketed over a window. The shape a chart consumes, and the only operation in this domain that is not a snapshot. ' additionalProperties: false required: - object - newsletter - metric - interval - period_start - period_end - buckets properties: object: type: string const: timeseries description: Always `timeseries`. newsletter: description: 'The newsletter the series describes. A `Ref` unless `newsletter` is named in `?expand=`. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Newsletter' metric: $ref: '#/components/schemas/TimeseriesMetric' interval: $ref: '#/components/schemas/TimeseriesInterval' period_start: type: string format: date-time description: 'Start of the resolved window, inclusive. The first bucket starts here. ' period_end: type: string format: date-time description: End of the resolved window, exclusive. buckets: type: array description: 'The series, oldest first. Buckets are contiguous and cover the whole window, so an empty bucket is returned with a `value` of `0` rather than omitted and a client never has to fill gaps. ' items: type: object additionalProperties: false required: - ts - value properties: ts: type: string format: date-time description: 'The instant the bucket opens, in UTC. A bucket covers `ts` up to the next bucket''s `ts`. ' value: type: integer minimum: 0 description: 'How much of `metric` fell inside this bucket. A count for that bucket alone, not a running total. ' 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 parameters: 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 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 StatsPeriod: name: period in: query required: false description: 'A named window, counted back from today in UTC. Defaults to `30d`. Ignored when `since` is given, so a request never has to reconcile two conflicting windows. ' schema: type: string enum: - 7d - 14d - 30d - 90d - 6m - 1y - all default: 30d 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 StatsUntil: name: until in: query required: false description: 'End of the window, exclusive, as a date or an RFC 3339 timestamp. Defaults to now. Only meaningful alongside `since`. ' schema: type: string maxLength: 40 examples: asDate: summary: A calendar day value: '2026-09-01' StatsSince: name: since in: query required: false description: 'Start of the window, inclusive, as a date or an RFC 3339 timestamp. Giving this overrides `period`. A `since` later than `until` answers `400`. Anything that is neither of those two shapes answers `400` as well, rather than being guessed at: a lenient parser would read `8/1/2026` as a date and give two callers different windows for the same string. ' schema: type: string maxLength: 40 examples: asDate: summary: A calendar day value: '2026-08-01' 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 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' 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' 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.