openapi: 3.2.0 info: title: Usecommune Engagement API version: '2026-08-26' contact: name: Commune url: https://usecommune.com email: support@usecommune.com description: 'Operations tagged Engagement 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: Engagement description: 'What Commune knows about one subscriber that a newsletter''s email provider cannot answer: engagement scored across the inbox and the community together, and the raw event stream those scores are summed from. Row shaped and high cardinality, which is what a CRM or a re-engagement automation reads. Needs `insights`, and part of the one read surface Commune may put behind a plan.' paths: /newsletters/{newsletter}/insights: parameters: - $ref: '#/components/parameters/CommuneVersion' - $ref: '#/components/parameters/NewsletterPath' get: operationId: listNewsletterInsights summary: List a newsletter's subscriber insights description: 'Per subscriber engagement scoring. Each row blends what a reader did in the newsletter''s community with what its email provider reported about them, carries a fourteen day over fourteen day velocity, and lands on a lifecycle status. This is what a CRM or a re engagement automation reads. Scores are recomputed by a scheduled pass rather than at read time, so a row is only as fresh as the last pass, and `last_action_at` can be newer than the score that reflects it. **Only people with a Commune account are scored.** Someone the newsletter knows only as an email address has no row here rather than a zero score, so the size of this collection is not the size of the audience. `status` is assigned by rank within the newsletter rather than by an absolute score, so it moves when the people around a reader move even if that reader did nothing. Ordered by `total_score` descending, so the first page is the newsletter''s most engaged readers. Needs `insights: read`, and is one of the reads Commune may put behind a plan: a credential whose newsletter is not entitled answers `402`. **`?expand=subscriber` saves a request per reader.** Each insight''s `subscriber` is a reference by default, and acting on the list (say, emailing the readers who are cooling off) would otherwise mean one `GET /subscribers/{subscriber}` per row. Expanded, every row carries the full `Subscriber`, the same object that operation returns, including `email` and `status`, in this one response. Accepted paths are `newsletter` and `subscriber`. Expanding `subscriber` puts email addresses in the response, so it also needs `audience: read` on this newsletter. A credential that holds `insights: read` without it is refused with `403` `insufficient_scope`, `param` set to `expand`, and `allowed_values` listing what it may expand instead; it is never handed references in place of the subscribers it asked for. A response that expands `subscriber` counts against the `audience` rate limit budget and is recorded in Commune''s audit log, like any other read of subscribers.' tags: - Engagement security: - apiKey: [] - oauth2: - insights:read parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Expand' - $ref: '#/components/parameters/Fields' - name: status in: query required: false description: 'Return only subscribers in this lifecycle status. Repeat the parameter to accept several. ' schema: $ref: '#/components/schemas/InsightStatus' - name: velocity in: query required: false description: 'Return only subscribers whose engagement is moving this way. Repeat the parameter to accept several. ' schema: $ref: '#/components/schemas/InsightVelocity' responses: '200': description: A page of subscriber insights, highest scoring first. content: application/json: schema: allOf: - $ref: '#/components/schemas/ListEnvelope' - type: object properties: data: type: array description: 'This page of scored readers, highest `total_score` first. Only people with a Commune account are scored, so somebody the newsletter knows only as an address has no entry at all rather than an entry scoring zero, and the length of this collection is not the size of the audience. Scores come from a scheduled pass rather than from read time, so `last_action_at` can be newer than the scores beside it, and `status` is assigned by rank within the newsletter, so it moves when the people around a reader move. ' items: $ref: '#/components/schemas/SubscriberInsight' examples: aSuperfanAndADormantReader: summary: The top of the ranking and a reader who has cooled, highest score first. value: object: list data: - object: subscriber_insight newsletter: object: newsletter id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411 subscriber: object: subscriber id: 33445566-7788-4990-a1b2-c3d4e5f60718 total_score: 412 community_score: 412 esp_score: 0 t1_score: 96 t2_score: 41 velocity: rising status: superfan share_points: 60 last_action_at: '2026-08-26T21:04:11Z' synced_to_esp_at: null - object: subscriber_insight newsletter: object: newsletter id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411 subscriber: object: subscriber id: 44556677-8899-4aa1-b2c3-d4e5f6071829 total_score: 88 community_score: 88 esp_score: 0 t1_score: 0 t2_score: 12 velocity: cooling status: dormant share_points: 0 last_action_at: '2026-07-02T09:12:40Z' synced_to_esp_at: null pagination: has_more: true next_cursor: Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4 aCoolingReaderWithTheSubscriberExpanded: summary: '`?expand=subscriber&status=dormant&velocity=cooling`: the readers to write to, with their addresses, in one request.' value: object: list data: - object: subscriber_insight newsletter: object: newsletter id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411 subscriber: object: subscriber id: 44556677-8899-4aa1-b2c3-d4e5f6071829 newsletter: object: newsletter id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411 user: object: user id: usr_2xR8kQ4mN7pL1vB9 email: dana@example.com status: subscribed source: commune tags: [] created_at: '2026-02-14T10:31:07Z' synced_at: null total_score: 88 community_score: 88 esp_score: 0 t1_score: 0 t2_score: 12 velocity: cooling status: dormant share_points: 0 last_action_at: '2026-07-02T09:12:40Z' synced_to_esp_at: null pagination: has_more: false next_cursor: null '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' servers: - url: https://api.usecommune.com description: 'Production. There is no separate sandbox host. ' /newsletters/{newsletter}/events: parameters: - $ref: '#/components/parameters/CommuneVersion' - $ref: '#/components/parameters/NewsletterPath' get: operationId: listNewsletterEvents summary: List a newsletter's engagement events description: 'The raw engagement stream the insight scores are built from, with both origins unified into one vocabulary: what a reader did inside the community, and what the newsletter''s email provider reported about the same person. `source` says which side an event came from. These are engagement records, not the events Commune pushes to a consumer. They describe reader behaviour and are read on request. What Commune pushes is the `webhooks` block of this document. Ordered by `id` descending, and the cursor walks the same key, so a warehouse can tail this collection and be sure that nothing inserted mid page is skipped. Every event is attributed to a Commune account, so a subscriber the newsletter knows only as an email address never appears here even when the provider reported an open for that address. Needs `insights: read`. A newsletter whose plan does not include insights answers `402`.' tags: - Engagement security: - apiKey: [] - oauth2: - insights:read parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Expand' - $ref: '#/components/parameters/Fields' - name: event_type in: query required: false description: 'Return only events of this kind. Repeat the parameter to accept several. ' schema: $ref: '#/components/schemas/EngagementEventType' - name: source in: query required: false description: 'Return only events from this origin. `community` is behaviour Commune observed directly, `esp` is behaviour the newsletter''s email provider reported. ' schema: $ref: '#/components/schemas/EngagementEventSource' responses: '200': description: A page of engagement events, most recent first. content: application/json: schema: allOf: - $ref: '#/components/schemas/ListEnvelope' - type: object properties: data: type: array description: 'This page of scored actions, ordered by `id` descending, which is the key the cursor walks: nothing inserted mid page is skipped, so a warehouse can tail this collection. `source` says which side an entry came from, behaviour Commune observed itself or behaviour the newsletter''s email provider reported, and the provider''s half arrives on that provider''s schedule. Every entry is attributed to a Commune account, so a subscriber known only as an address never appears here even when the provider reported something for that address. ' items: $ref: '#/components/schemas/EngagementEvent' examples: oneFromEachSide: summary: A community action and a provider reported one, newest first. value: object: list data: - object: engagement_event id: '4815162342' newsletter: object: newsletter id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411 subscriber: object: subscriber id: 33445566-7788-4990-a1b2-c3d4e5f60718 event_type: like source: community points: 5 metadata: article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f created_at: '2026-08-26T21:04:11Z' - object: engagement_event id: '4815162299' newsletter: object: newsletter id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411 subscriber: object: subscriber id: 33445566-7788-4990-a1b2-c3d4e5f60718 event_type: email_open source: esp points: 1 metadata: article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f created_at: '2026-08-26T09:41:06Z' pagination: has_more: true next_cursor: Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4 '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' 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. EngagementEventType: type: string title: EngagementEventType description: 'What a reader did. The first five happen on Commune and the last two are reported by the newsletter''s email provider, which is what `source` records. ' enum: - view - like - link_click - comment - share - email_open - email_click 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 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 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. InsightStatus: type: string title: InsightStatus description: 'Where a reader sits in the newsletter''s engagement ladder, from `dormant` at the bottom to `superfan` at the top. Assigned by rank inside the newsletter rather than against an absolute score, so it is a statement about this audience and never comparable between two newsletters. It also means a reader can move without doing anything, because the people around them moved. ' enum: - superfan - engaged - reader - dormant 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. ' InsightVelocity: type: string title: InsightVelocity description: 'Which way a reader''s engagement is moving, from the last fourteen days against the fourteen before them. `steady` also covers a reader with no activity in either window, so read it with `status`, where that reader is `dormant`. ' enum: - rising - cooling - steady EngagementEvent: type: object title: EngagementEvent description: 'One scored action by one reader, unaggregated, so a consumer can build its own model rather than take Commune''s scores. The subscriber insight scores are summed from exactly these. They are engagement records, not the events Commune pushes to a consumer. What Commune pushes is the `webhooks` block of this document. ' additionalProperties: false required: - object - id - newsletter - subscriber - event_type - source - points - created_at properties: object: type: string const: engagement_event description: Always `engagement_event`. id: type: string description: 'Monotonically increasing identifier, returned as a string because it outgrows a double before a busy newsletter is done with it. This collection is ordered by it and the cursor walks it, which is what makes tailing safe. ' examples: - '4815162342' newsletter: description: 'The newsletter the action was aimed at. A `Ref` unless `newsletter` is named in `?expand=`. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Newsletter' subscriber: description: 'Who acted. A `Ref` unless `subscriber` is named in `?expand=`. Attribution needs a Commune account, so an event is never recorded against an address the newsletter knows only from an import. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Subscriber' event_type: $ref: '#/components/schemas/EngagementEventType' source: $ref: '#/components/schemas/EngagementEventSource' points: type: integer description: 'What this action contributed to the reader''s score. The weights are Commune''s own and may be retuned, so treat this as the value at the time the event was recorded rather than a constant per `event_type`. ' metadata: type: object additionalProperties: true description: 'What the action was aimed at, which varies by `event_type`: an article id, a message id, a clicked URL. Loosely typed, and a key present today may be absent tomorrow, so read it defensively. ' created_at: type: string format: date-time description: When the reader acted. 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. SubscriberInsight: type: object title: SubscriberInsight description: 'One reader''s engagement with one newsletter, scored across both the email and the community. Recomputed on a schedule, not at read time. Scores have no unit and no ceiling. They are sums of weighted actions, so they are meaningful ranked against each other inside one newsletter and meaningless compared between two. ' additionalProperties: false required: - object - newsletter - subscriber - total_score - community_score - esp_score - t1_score - t2_score - velocity - status - share_points properties: object: type: string const: subscriber_insight description: Always `subscriber_insight`. newsletter: description: 'The newsletter the reader is scored against. A `Ref` unless `newsletter` is named in `?expand=`. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Newsletter' subscriber: description: 'The scored reader''s membership of this newsletter. A `Ref` unless `subscriber` is named in `?expand=`, and then the full `Subscriber`, `email` and `status` included, exactly as `GET /subscribers/{subscriber}` returns it. Expanding it needs `audience: read` as well as `insights: read`, because it puts an email address in the response. Scoring is attributed to a Commune account, so this is always a subscriber who has one. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Subscriber' total_score: type: integer minimum: 0 description: 'The blended lifetime score, and the field this collection is ordered by. It is `community_score` plus `esp_score`. ' community_score: type: integer minimum: 0 description: 'The part of the score earned on Commune: reading, liking, highlighting, replying and sharing. ' esp_score: type: integer minimum: 0 description: 'The part of the score earned in the inbox, from what the newsletter''s email provider reported. Always `0` for a `commune` newsletter, whose email signal arrives through the send pipeline instead. ' t1_score: type: integer minimum: 0 description: Points earned in the last fourteen days. t2_score: type: integer minimum: 0 description: 'Points earned in the fourteen days before those. `velocity` is the comparison of `t1_score` against this. ' velocity: $ref: '#/components/schemas/InsightVelocity' status: $ref: '#/components/schemas/InsightStatus' share_points: type: integer minimum: 0 description: 'The part of `total_score` earned by putting the newsletter in front of someone else rather than by consuming it. The signal a referral programme wants. ' last_action_at: type: - string - 'null' format: date-time description: 'When this reader last did anything that earned points. May be newer than the scores beside it, because the scores are recomputed on a schedule and this is the raw event time. `null` for a reader who has never acted. ' synced_to_esp_at: type: - string - 'null' format: date-time description: 'When Commune last wrote this reader''s status back to the newsletter''s email provider, so the creator can segment on it there. `null` when it has never been synced, and always `null` for a newsletter with no provider to sync to. ' EngagementEventSource: type: string title: EngagementEventSource description: 'Which side of the newsletter an event came from. `community` is behaviour Commune observed itself. `esp` is behaviour the newsletter''s email provider reported, so it arrives on that provider''s schedule and is only as complete as that provider''s reporting. ' enum: - community - esp 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 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 SubscriberStatus: type: string title: SubscriberStatus description: 'Where a subscription stands. Source of truth for a `commune` newsletter. For a newsletter connected to an outside provider it reflects what Commune last saw of the provider''s state. ' enum: - subscribed - unsubscribed - bounced - complained - pending Subscriber: type: object title: Subscriber description: 'One person''s membership of one newsletter. The same person subscribing to two newsletters is two subscribers, and one creator never sees the other''s row. A subscriber may or may not have a Commune account. Someone who joined by email, or who arrived in an import from the newsletter''s provider, has an `email` and no `user`. Someone who joined through Commune has both. ' additionalProperties: false required: - object - id - newsletter - email - status - created_at properties: object: type: string const: subscriber description: Always `subscriber`. id: type: string format: uuid description: Stable identifier for this membership. newsletter: description: 'The newsletter subscribed to. A `Ref` unless `newsletter` is named in `?expand=`. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Newsletter' user: description: 'The Commune account behind the subscription, or `null` for someone who joined by email without one. A `Ref` unless `user` is named in `?expand=`. ' anyOf: - type: 'null' - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/User' email: type: string format: email description: 'Where the newsletter reaches this person. Returned on every subscriber, including one with a Commune account behind them. For a subscriber with no account it is the address they subscribed with. For one with an account it is the address on that account, which is where the article is actually delivered. Never null. **This is the one place in the API an address appears.** It is a property of the subscription rather than of the person, so it is never on a public profile, never on an expanded author, and never reachable from a credential granted another newsletter. Reading it needs `audience: read`, counts against the tighter `audience` rate limit budget, and is recorded in Commune''s audit log with the credential and how many subscribers it read. ' status: $ref: '#/components/schemas/SubscriberStatus' source: type: string enum: - commune - imported - unknown description: 'How the subscription was made. `commune` when the person subscribed through Commune itself, by pressing subscribe, finishing signup or accepting an invitation. `imported` when they arrived in an import of the newsletter''s provider list or a file. **Not the same question as whether Commune grew the list.** Somebody who subscribes through Commune to a newsletter whose provider already held their address is `commune` here, even though the creator''s list did not get longer. `unknown` for a subscription recorded before Commune kept this. It is stated rather than left out, so "Commune does not know" cannot be mistaken for `imported`. ' tags: type: array description: 'The audience tags this subscriber holds, which is what decides which tag scoped articles reach them. Each entry is a `Ref` unless `tags` is named in `?expand=`. ' items: oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Tag' created_at: type: string format: date-time description: When Commune first recorded the subscription. synced_at: type: - string - 'null' format: date-time description: 'When this row was last reconciled with the newsletter''s provider. Null for a `commune` newsletter, which has no provider to reconcile with. ' Tag: type: object title: Tag description: 'A segment of a newsletter''s audience. A tag is what makes an article audience scoped: sending to a tag stamps the article, and from then on only holders of that tag and the newsletter''s team can read it. ' additionalProperties: false required: - object - id - newsletter - name - retired - created_at properties: object: type: string const: tag description: Always `tag`. id: type: string format: uuid description: Stable identifier. newsletter: description: 'The newsletter the tag belongs to. A tag never spans newsletters. A `Ref` unless `newsletter` is named in `?expand=`. ' oneOf: - $ref: '#/components/schemas/Ref' - $ref: '#/components/schemas/Newsletter' name: type: string description: 'What the creator calls the segment. Unique among the newsletter''s live tags, and freed for reuse once a tag is retired. ' known_subscriber_count: type: integer minimum: 0 description: 'How many currently subscribed people Commune knows of who hold this tag. Counted at read time from Commune''s own record of the audience, which for a newsletter connected to an outside provider is a partial cache of that provider''s list. Named `known_` for that reason: it is a floor, never the segment''s true size, and it must not be presented as one. ' retired: type: boolean description: '`true` when the creator removed the tag but an already sent article is still addressed to it. Retired tags keep their assignments, because the audience of a sent article does not change retroactively. Excluded from the tag list unless `include_retired=true`. ' retired_at: type: - string - 'null' format: date-time description: When the tag was retired. Null while it is live. created_at: type: string format: date-time description: When the tag was created. 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' 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: 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 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' 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.