# openapi "docs" profile. GENERATED, DO NOT EDIT.
#
# apps/api/spec/openapi.base.yaml
#   + apps/api/spec/overlay.docs.yaml
#   = this file, written by apps/api/spec/tools/apply-overlays.mjs.
#
# Run `npm run spec:profiles` to rebuild it. Edit the base document or the
# overlay, never this file: it is not under version control, and the next
# build overwrites it.
#
# Comments in the authored documents do not survive the YAML round trip, so
# their absence below is expected and is not a loss of contract.
openapi: 3.1.0
info:
  title: Commune API
  version: "2026-08-26"
  summary: Read access to newsletters, articles, threads and the people around them.
  description: |
    The Commune API exposes the public reading surface of a Commune community:
    newsletters, the articles they publish, the chat threads those articles
    start, and the people who write and read them.

    ## Versioning

    The base URL carries no version segment. A request selects a contract
    version with the `Commune-Version` header, whose value is the release date
    of the contract (for example `2026-08-26`). Omitting the header pins the
    request to the version that was current when the API key was issued.

    Every response echoes the version it resolved to in a `Commune-Version`
    response header, on success and on failure alike. A client that never sets
    the header can read which contract it has been getting, and compare it
    against `version` in `GET /status` to find out whether a newer one is
    available to move to.

    Every response also carries a `Commune-Request-Id`, which is the value that
    appears as `request_id` in an error body. Quote it in support requests.

    ## Authentication

    Every request is authenticated with an API key sent as a bearer token. A key
    carries either public scope (it can read only what is already readable by an
    anonymous visitor on the web) or creator scope (it additionally reads the
    private surface of the newsletters it is bound to). An operation documented
    as creator scope answers `403` to a public key.

    ## Rate limits

    Every request is counted against the key that made it, never against an
    address. Two budgets apply. The general budget counts every request. A
    second, tighter budget counts only the operations that return subscriber or
    recipient email addresses, because a key that can read those can export a
    newsletter's audience; those operations are charged to both and have to pass
    both.

    From the moment a key resolves, every response carries `RateLimit-Limit`,
    `RateLimit-Remaining` and `RateLimit-Reset` for whichever budget is closest
    to exhaustion, and `RateLimit-Policy` listing every budget that applied.
    `RateLimit-Reset` is in seconds from now. A `429` additionally carries
    `Retry-After`, and its `message` names the budget that refused, since being
    refused by the audience budget still leaves the rest of the API callable.

    `GET /rate-limit` reports every budget at once, which is what a client
    should read rather than inferring the whole picture from the one budget the
    headers can describe.

    ## Pagination

    Collections are cursor paginated. A response carries `data` plus a
    `pagination` object holding an opaque `next_cursor`. Pass it back as
    `?cursor=` to fetch the following page. There is no offset, limit-offset or
    page number, and a cursor is not a durable identifier.

    ## Identifiers

    Resources that are addressable in the product carry both a UUID `id` and a
    short, URL friendly `short_id`. Either value is accepted wherever a path
    parameter names that resource.
  contact:
    name: Commune
    url: https://usecommune.com
    email: support@usecommune.com
  termsOfService: https://usecommune.com/terms
servers:
  - url: https://api.usecommune.com
    description: |
      Production. There is no separate sandbox host.
tags:
  - name: Newsletters
    description: |
      A newsletter is the top level object in Commune. It owns its articles, its
      chat, its subscribers and its team. Everything else in this catalog hangs
      off one.
  - name: Team
    description: |
      Who may act on behalf of a newsletter: its owner, plus the members the
      owner added as admins, editors or guests.
  - name: Users
    description: |
      A person with a Commune account: the readers who join a community and the
      writers who are credited on an article. Only the public profile is
      readable, never an email address.
  - name: Search
    description: |
      One query across newsletters, articles, people and chat. Where a reader
      starts who does not yet have an identifier for any of them.
  - name: Articles
    description: |
      An article is one issue of a newsletter: written natively in Commune and
      sent, or imported from the newsletter's provider. Two rules gate every
      article read and are described on each operation. First, an article
      stamped with an audience is visible only to the newsletter's team and to
      subscribers holding one of its tags. Second, an article dated in the
      future is invisible until that moment passes.
  - name: Highlights
    description: |
      A highlight is a passage of an article a reader marked. It anchors a
      comment to the exact sentence that prompted it.
  - name: Threads
    description: |
      A thread is a conversation inside a newsletter's community. Commune has no
      separate posts or comments stack: a creator's broadcast, a reader's
      question and the discussion under an article are all threads in the same
      newsletter scoped chat.
  - name: Messages
    description: |
      A message is a reply inside a thread, up to two levels deep. Reactions
      hang off a message.
  - name: Subscribers
    description: |
      Who receives a newsletter. Creator scope throughout, and never a public
      surface: a newsletter's list belongs to its creator.
  - name: Subscriber tags
    description: |
      A tag segments a newsletter's audience. Sending an issue to a tag stamps
      that issue with an audience, which is what makes it invisible to everyone
      outside it. Named for the subscribers it is applied to, because a tag
      called `Tags` inside a document made of tags says nothing.
  - 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. Creator scope, and part of the one read surface Commune
      may put behind a plan.
  - name: Metrics
    description: |
      The rolled up numbers for a newsletter and for one issue: headline stats
      for a period, acquisition attribution, bucketed series for charting, and
      one issue'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.
  - name: Senders
    description: |
      The addresses a newsletter sends from, and the state of the DNS that has
      to be in place for them to work. The sending half of the pair; Website
      domains is the other. Creator scope.
  - name: Website domains
    description: |
      A creator's own domain pointed at their Commune site, so their community
      lives at their address rather than at ours. The same prove you own this
      hostname flow as Senders, pointed at the site rather than at the mail.
      Creator scope.
  - name: Platform
    description: |
      The API's own machinery rather than any newsletter's data. Today that is
      the readiness probe; key management, the rate limit view and the
      machine readable contract documents join it as they land.
  - name: Webhooks
    description: |
      The events Commune pushes to a consumer, rather than the resources a
      consumer pulls. Commune publishes state changes on 21 topics, each
      delivered as one HTTPS POST to an endpoint the consumer registered.

      Every message shares one envelope, so a consumer can route on `type` and
      dedupe on `id` without knowing anything about the specific event, and the
      same values arrive as `Commune-Event-Type` and `Commune-Event-Id` headers
      so both can be read before the body is parsed.

      Delivery is at least once and unordered. A non-2xx response or a timeout
      is retried with backoff, so a consumer has to treat `id` as the dedupe key
      and tolerate replays. `occurred_at` is the ordering field, not arrival
      time.

      Two envelope fields, `actor` and `idempotency_key`, are declared and are
      always `null` today. They are part of the contract from the start so that
      populating them later is an additive change rather than a new envelope
      version. See "Reserved nulls" on the envelope schema.

      Registration and the signature algorithm itself are not expressible as a
      Path Item and are documented in prose instead.
security:
  - apiKey: []
paths:
  /status:
    get:
      operationId: getStatus
      summary: Service status
      description: |
        Whether the API is serving, what it depends on to serve, and which
        contract version it is currently on. Reaching this operation at all
        proves the process is up and routing; `status` and `dependencies` say
        whether it is up in a useful sense.

        Every check is shallow. It proves that a dependency answers, not that
        it answers correctly, so read a `down` as a reason to stop retrying and
        never an `up` as a guarantee that a write elsewhere will land.

        The answer is deliberately coarse. Because this operation is
        unauthenticated, it reports capability names and states and nothing
        else: no vendor, no free text, and no distinction between a dependency
        that failed its probe and one this deployment cannot reach at all.

        Unauthenticated, and the one operation that ignores `Commune-Version`,
        since it describes the service rather than a resource. Because it takes
        no credential there is no key to count against, so the per-key budgets
        described under Rate limits do not apply to it and it carries no
        `RateLimit-*` headers. It can still be refused by whatever sits in front
        of this service, which is why the `429` stays declared.
      tags:
        - Platform
      security: []
      responses:
        "200":
          description: |
            The service is serving traffic. Read `status` before trusting it to
            serve every operation: a `200` here reports a degradation rather
            than hiding it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceStatus"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/status
  /rate-limit:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
    get:
      operationId: getRateLimit
      summary: Retrieve the calling key's rate limit state
      description: |
        What this key has left, on every budget that applies to it, measured
        after this request has been counted.

        Two budgets exist. `general` counts every request made with the key.
        `audience` counts only the operations that return subscriber or
        recipient email addresses, and is smaller: those operations are charged
        to both budgets and have to pass both, so a key that has exhausted
        `audience` can still call everything else.

        Read this rather than inferring the whole picture from the `RateLimit-*`
        headers. Those describe one budget, whichever is closest to exhaustion,
        because a retry loop can only act on one number.

        This operation is itself counted against the `general` budget. An
        endpoint exempt from the limit would be the one call a client could make
        in a loop for free.
      tags:
        - Platform
      responses:
        "200":
          description: The current window, limit and remaining for this key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RateLimit"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
  /newsletters:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
    get:
      operationId: listNewsletters
      summary: List newsletters
      description: |
        The newsletters the key can see. A public key sees the newsletters that
        have a public profile page on the web. A creator key sees the
        newsletters it is bound to, whether or not they are public.

        Ordered by `created_at` descending.
      tags:
        - Newsletters
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
        - name: esp
          in: query
          required: false
          description: |
            Return only newsletters published through this provider. Repeat the
            parameter to accept several.
          schema:
            $ref: "#/components/schemas/Esp"
        - name: handle
          in: query
          required: false
          description: |
            Return only the newsletter with this handle. A handle is unique
            across Commune, so this answers zero or one item. Prefer
            `GET /newsletters/{newsletter}` when you want a single object and a
            `404` rather than an empty page.
          schema:
            type: string
      responses:
        "200":
          description: A page of newsletters.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Newsletter"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl "https://api.usecommune.com/newsletters?limit=20" \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /newsletters/{newsletter}:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/NewsletterPath"
    get:
      operationId: getNewsletter
      summary: Retrieve a newsletter
      description: |
        Read one newsletter by `id` or by `handle`. A newsletter has no
        `short_id`: its handle is the short, human readable identifier, and it
        is what the web profile at `/n/{handle}` resolves on.
      tags:
        - Newsletters
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: The newsletter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Newsletter"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/newsletters/the-weekly \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /newsletters/{newsletter}/articles:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/NewsletterPath"
    get:
      operationId: listNewsletterArticles
      summary: List a newsletter's articles
      description: |
        The newsletter's articles, most recently published first, ordered by
        `posted_at` descending with articles that never got a date last.

        Two gates apply and neither can be turned off. An article stamped with
        an audience is returned only to a key that may read that audience: the
        newsletter's owner, an admin or editor, or a subscriber holding one of
        the article's tags. An article whose `posted_at` is in the future is not
        returned at all until that moment passes, so a scheduled issue never
        leaks early through this collection.

        `content` is never included here, whatever `?fields=` asks for. An issue
        body is large enough that returning a page of them is the wrong default,
        so read it from `GET /articles/{article}`.
      tags:
        - Articles
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
        - name: status
          in: query
          required: false
          description: |
            Return only articles in this state. A public key may only ask for
            `sent`, since a draft or a failed send is not public. Repeat the
            parameter to accept several.
          schema:
            $ref: "#/components/schemas/ArticleStatus"
        - name: imported
          in: query
          required: false
          description: |
            `true` returns only articles imported from the newsletter's
            provider, `false` only articles written natively in Commune. Omit
            for both.
          schema:
            type: boolean
        - name: tag
          in: query
          required: false
          description: |
            Return only articles stamped with this subscriber tag, by tag `id`.
            Creator scope, because the audience of an issue is not public.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: A page of articles, each without `content`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Article"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            "https://api.usecommune.com/newsletters/the-weekly/articles?status=sent&limit=10"
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /articles/{article}:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/ArticlePath"
    get:
      operationId: getArticle
      summary: Retrieve an article
      description: |
        Read one article, including its `content`. This is the only operation
        that returns a body.

        The same two gates apply as on the list. An article stamped with an
        audience answers `404` to a key that does not hold that audience, and a
        future dated article answers `404` until it goes live, including to the
        newsletter's own team, so that a preview link cannot be shared early.

        For a natively published issue, `content` is the email rendered to HTML
        with personalization placeholders resolved against an empty context, so
        a merge tag never leaks as raw text. For an imported issue it is the
        body as it arrived from the provider.

        `?expand=content` adds `content_markdown`, the same body as Markdown.
        Ask for it when a model is going to read the issue, and ask for
        `?fields=content_markdown` with it to leave the HTML behind entirely.
      tags:
        - Articles
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: The article, with `content`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ArticleWithContent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/articles/k7Rm2xQp \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /articles/{article}/authors:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/ArticlePath"
    get:
      operationId: listArticleAuthors
      summary: List an article's authors
      description: |
        Everyone credited on the byline, in the order the creator arranged them.
        An article usually has one author, but an imported issue can be
        attributed to several team members through the provider's creator field,
        and a native issue can be co-signed.

        An article whose author was never mapped to a Commune account returns an
        empty page rather than a placeholder person.
      tags:
        - Articles
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: A page of authors, in byline order.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/articles/k7Rm2xQp/authors \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /newsletters/{newsletter}/senders:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/NewsletterPath"
    get:
      operationId: listNewsletterSenders
      summary: List a newsletter's sending addresses
      description: |
        The addresses this newsletter can send from, the default one first.
        Creator scope.

        A newsletter that Commune sends natively is provisioned with one address
        on a Commune owned domain, and the creator can add their own on top. An
        address is only usable once its `verification_status` is `verified`,
        which depends on DNS the creator controls, so an integration that
        schedules a send should check this first.
      tags:
        - Senders
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: A page of sending addresses, default first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Sender"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/newsletters/the-weekly/senders \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /senders/{sender}:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/SenderPath"
    get:
      operationId: getSender
      summary: Retrieve a sending address
      description: |
        Read one sending address, including the DNS records the creator has to
        publish and the reason verification last failed. Creator scope.
      tags:
        - Senders
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: The sending address.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Sender"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            https://api.usecommune.com/senders/2f8b0c1a-5d34-4e7b-8f21-6a9c4b3d2e10
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /newsletters/{newsletter}/domains:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/NewsletterPath"
    get:
      operationId: listNewsletterDomains
      summary: List a newsletter's website domains
      description: |
        The creator's own domains pointed at this newsletter's Commune site.
        Creator scope.

        Two independent things have to be true for one to work, and they are
        reported separately. `verification_status` says whether ownership was
        proved and a certificate issued. `routing_ok` says whether the domain
        actually resolves to Commune, which a certificate says nothing about.
      tags:
        - Website domains
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: A page of website domains.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Domain"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/newsletters/the-weekly/domains \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /domains/{domain}:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/DomainPath"
    get:
      operationId: getDomain
      summary: Retrieve a website domain
      description: |
        Read one website domain, including the records to publish and the last
        reachability probe. Creator scope.
      tags:
        - Website domains
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: The website domain.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Domain"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/domains/blog.example.org \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /search:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
    get:
      operationId: search
      summary: Search Commune
      description: |
        Full text search across newsletters, articles, people and chat, ranked
        by relevance.

        Results obey the same gates as the resource operations do. An article
        stamped with an audience, an article dated in the future, and a thread
        that is not public are not returned to a key that could not read them
        directly, so search cannot be used to see around a permission.

        A query shorter than two characters returns an empty page rather than an
        error, so a caller can send it on every keystroke.
      tags:
        - Search
      parameters:
        - name: q
          in: query
          required: true
          description: The query. Shorter than two characters returns an empty page.
          schema:
            type: string
            maxLength: 200
        - name: type
          in: query
          required: false
          description: |
            Return only results of this kind. Repeat the parameter to accept
            several. Omit for all kinds.
          schema:
            $ref: "#/components/schemas/SearchResultType"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: A page of results, most relevant first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/SearchResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl "https://api.usecommune.com/search?q=onboarding&type=article" \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /articles/{article}/highlights:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/ArticlePath"
    get:
      operationId: listArticleHighlights
      summary: List an article's highlights
      description: |
        The passages readers marked in this article, in the order they appear in
        the body. Use `start_offset` and `end_offset` to place them, and `quote`
        with `prefix` and `suffix` to re anchor when the body has since changed.

        Highlights are not attributed to a named person. Each carries an
        `owner_key`, an opaque value that is stable per highlighter within one
        article and meaningless across articles, so a client can count distinct
        highlighters and group one person's marks without learning who they are.
      tags:
        - Highlights
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: A page of highlights, in body order.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Highlight"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/articles/k7Rm2xQp/highlights \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /highlights/{highlight}:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/HighlightPath"
    get:
      operationId: getHighlight
      summary: Retrieve a highlight
      description: |
        Read one highlight. It answers `404` whenever its article would, so a
        highlight cannot be used to read a passage of an issue the key is not
        entitled to.
      tags:
        - Highlights
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: The highlight.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Highlight"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            https://api.usecommune.com/highlights/7c1d9e4b-2a86-4f30-9b57-1e8d6c5a4f32
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /newsletters/{newsletter}/tags:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/NewsletterPath"
    get:
      operationId: listNewsletterTags
      summary: List a newsletter's tags
      description: |
        The newsletter's live audience tags, alphabetical, each with a count of
        currently subscribed holders. Creator scope: how a creator segments
        their audience is not public.

        Retired tags are excluded by default. A tag that an already sent issue
        was addressed to cannot be deleted outright, because the audience of a
        sent issue does not change retroactively, so it is retired instead and
        keeps its assignments. Pass `include_retired=true` to see those when
        rendering the audience of a historical issue.
      tags:
        - Subscriber tags
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
        - name: include_retired
          in: query
          required: false
          description: |
            Include tags that were retired. They still resolve the audience of
            issues sent before they were retired.
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: A page of tags, alphabetical.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Tag"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            "https://api.usecommune.com/newsletters/the-weekly/tags?include_retired=true"
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /tags/{tag}:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/TagPath"
    get:
      operationId: getTag
      summary: Retrieve a tag
      description: |
        Read one tag by `id`, retired or not. Creator scope, and only for a
        newsletter the key is bound to.
      tags:
        - Subscriber tags
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: The tag.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tag"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            https://api.usecommune.com/tags/4e2a7f19-8c53-4d6b-a710-3f9b2c8d5e64
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /newsletters/{newsletter}/members:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/NewsletterPath"
    get:
      operationId: listNewsletterMembers
      summary: List a newsletter's team
      description: |
        Everyone who can act on behalf of the newsletter, oldest membership
        first. The owner is included as a synthetic member with the `owner`
        role, so a client does not have to fold `newsletter.owner` in by hand.

        Creator scope. Pending invitations are not members and are not returned
        here.
      tags:
        - Team
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
        - name: role
          in: query
          required: false
          description: |
            Return only members with this role. Repeat the parameter to accept
            several.
          schema:
            $ref: "#/components/schemas/MemberRole"
      responses:
        "200":
          description: A page of team members.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Member"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            "https://api.usecommune.com/newsletters/the-weekly/members?role=editor"
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /newsletters/{newsletter}/subscribers:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/NewsletterPath"
    get:
      operationId: listNewsletterSubscribers
      summary: List a newsletter's subscribers
      description: |
        The newsletter's subscribers, most recent first. Creator scope, and
        returns email addresses, so a key that can call this holds the
        newsletter's list.

        This is Commune's own record of the audience. For a newsletter that
        Commune sends natively it is the source of truth. For a newsletter
        connected to an outside provider it is a partial cache of that
        provider's list, filled in by imports and by people who joined through
        Commune, and it is not the newsletter's subscriber count of record. Ask
        the provider for that number, and do not present a count derived from
        this collection as a total.
      tags:
        - Subscribers
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
        - name: status
          in: query
          required: false
          description: |
            Return only subscribers in this state. Defaults to `subscribed`.
            Repeat the parameter to accept several.
          schema:
            $ref: "#/components/schemas/SubscriberStatus"
        - name: tag
          in: query
          required: false
          description: Return only subscribers holding this tag, by tag `id`.
          schema:
            type: string
            format: uuid
        - name: source
          in: query
          required: false
          description: |
            `commune` returns only the people who joined through Commune,
            `imported` only those that came from the newsletter's provider or a
            file. Omit for both.
          schema:
            type: string
            enum:
              - commune
              - imported
      responses:
        "200":
          description: A page of subscribers, most recently added first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Subscriber"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            "https://api.usecommune.com/newsletters/the-weekly/subscribers?status=subscribed&limit=100"
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /subscribers/{subscriber}:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/SubscriberPath"
    get:
      operationId: getSubscriber
      summary: Retrieve a subscriber
      description: |
        Read one subscriber by `id`. Creator scope, and only within a newsletter
        the key is bound to: the same person subscribing to two newsletters is
        two subscribers, and one creator never sees the other's row.
      tags:
        - Subscribers
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: The subscriber.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscriber"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            https://api.usecommune.com/subscribers/b81f3d27-6e40-4a95-8c13-5d7e2f0a9b64
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /newsletters/{newsletter}/threads:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/NewsletterPath"
    get:
      operationId: listNewsletterThreads
      summary: List a newsletter's threads
      description: |
        The conversations in this newsletter's community, most recently active
        first, ordered by `last_activity_at` descending.

        A public key sees only threads whose `visibility` is `public`. A creator
        key additionally sees `subscribers` and `paid` threads, which are the
        ones a reader has to have joined the newsletter to read.

        Threads opened by an article are included and carry `is_article_thread`
        as `true`. Filter them out with `is_article_thread=false` when you want
        only what people started themselves.
      tags:
        - Threads
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
        - name: visibility
          in: query
          required: false
          description: |
            Return only threads with this placement. Asking for anything other
            than `public` with a public key answers `403`. Repeat the parameter
            to accept several.
          schema:
            $ref: "#/components/schemas/ThreadVisibility"
        - name: is_article_thread
          in: query
          required: false
          description: |
            `true` returns only the discussions Commune opened under an article,
            `false` only the threads people started themselves. Omit for both.
          schema:
            type: boolean
      responses:
        "200":
          description: A page of threads.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Thread"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            "https://api.usecommune.com/newsletters/the-weekly/threads?visibility=public"
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /threads/{thread}:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/ThreadPath"
    get:
      operationId: getThread
      summary: Retrieve a thread
      description: |
        Read one thread and its opening message. The replies are a separate
        collection at `GET /threads/{thread}/messages`, so a busy thread does
        not make this response unbounded.

        A thread opened by an article inherits that article's audience: if the
        article is not readable by this key, neither is its thread.
      tags:
        - Threads
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: The thread.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Thread"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/threads/b3Xn8kTw \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /threads/{thread}/messages:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/ThreadPath"
    get:
      operationId: listThreadMessages
      summary: List a thread's messages
      description: |
        Every reply in the thread, oldest first, flattened. A reply to a reply
        is in the same page as its parent and points at it through `parent`, so
        a client rebuilds the two level tree without a second request.

        Deleted messages are omitted rather than tombstoned.
      tags:
        - Messages
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: A page of messages, oldest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/threads/b3Xn8kTw/messages \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /messages/{message}:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/MessagePath"
    get:
      operationId: getMessage
      summary: Retrieve a message
      description: |
        Read one reply. A message inherits the visibility of the thread it sits
        in, so this answers `404` whenever the thread would.
      tags:
        - Messages
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: The message.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/messages/p9Wd4vHs \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /messages/{message}/reactions:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/MessagePath"
    get:
      operationId: listMessageReactions
      summary: List a message's reactions
      description: |
        The emoji reactions on a message, one entry per distinct emoji with the
        people who left it. Ordered by `count` descending, then by the emoji
        itself so the order is stable between two reads that tie.
      tags:
        - Messages
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: A page of reactions, most used first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Reaction"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/messages/p9Wd4vHs/reactions \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /users/{user}:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/UserPath"
    get:
      operationId: getUser
      summary: Retrieve a user
      description: |
        Read one public profile by `id` or by `username`. This is the whole
        public shape of a person in Commune. Email addresses, notification
        settings, saved articles and read state are never returned here: they
        belong to the person, not to a key.
      tags:
        - Users
      parameters:
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: The user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/users/ada \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /users/{user}/newsletters:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/UserPath"
    get:
      operationId: listUserNewsletters
      summary: List the newsletters a user publishes
      description: |
        The newsletters this person owns or is a team member of, filtered to
        those the key may see. It answers "what does this writer publish", not
        "what does this reader follow", which is
        `GET /users/{user}/subscriptions`.
      tags:
        - Users
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: A page of newsletters.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Newsletter"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/users/ada/newsletters \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /users/{user}/subscriptions:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/UserPath"
    get:
      operationId: listUserSubscriptions
      summary: List the newsletters a user reads
      description: |
        The newsletters this person subscribes to. Creator scope, and only for
        the newsletters the key is bound to: a reader's full reading list across
        Commune is theirs, so a key never sees subscriptions to newsletters it
        does not own.
      tags:
        - Users
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: A page of newsletters.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Newsletter"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/users/ada/subscriptions \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /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. It is the object a CRM or a re engagement automation
        reads, and the one thing in this catalog no email provider can answer,
        because half the signal never reaches it.

        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, because engagement is
        attributed to an account. Someone the newsletter knows only as an email
        address has no row here and is absent from this collection rather than
        present with a zero score, so the size of this collection is not the
        size of the audience.

        `status` is assigned by rank within the newsletter, not 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. Creator scope, and the one read
        Commune may put behind a plan: a key without it answers `402`.
      tags:
        - Engagement
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
        - name: status
          in: query
          required: false
          description: |
            Return only subscribers in this lifecycle status. Repeat the
            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
                        items:
                          $ref: "#/components/schemas/SubscriberInsight"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            "https://api.usecommune.com/newsletters/the-weekly/insights?status=superfan"
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /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.

        Creator scope, and gated with the rest of this domain: a key whose plan
        does not include it answers `402`.
      tags:
        - Engagement
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
        - name: event_type
          in: query
          required: false
          description: |
            Return only events of this kind. Repeat the parameter to accept
            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
                        items:
                          $ref: "#/components/schemas/EngagementEvent"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            "https://api.usecommune.com/newsletters/the-weekly/events?source=esp&limit=100"
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /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
        newsletter Commune sends natively. 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 issues Commune has a
        record of and is never a count of emails delivered, which lives in
        `delivery`.
      tags:
        - Metrics
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
        - $ref: "#/components/parameters/StatsPeriod"
        - $ref: "#/components/parameters/StatsSince"
        - $ref: "#/components/parameters/StatsUntil"
      responses:
        "200":
          description: The newsletter's numbers for the resolved window.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewsletterStats"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            "https://api.usecommune.com/newsletters/the-weekly/stats?period=90d"
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /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 acquisition source, how many of them arrived through Commune itself,
        and how the invite funnel performed.

        A subscriber's source is frozen when the row is first written, so
        migrating a newsletter between providers later never relabels the
        history.

        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.

        `invites` describes Commune's own invitations to the people on the list
        who have no Commune account yet. Opting out of those is deliberately
        separate from unsubscribing from the newsletter, so `opted_out` here
        says nothing about whether those people still receive the email.
      tags:
        - Metrics
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
        - $ref: "#/components/parameters/StatsPeriod"
        - $ref: "#/components/parameters/StatsSince"
        - $ref: "#/components/parameters/StatsUntil"
      responses:
        "200":
          description: The acquisition breakdown for the resolved window.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewsletterGrowth"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            "https://api.usecommune.com/newsletters/the-weekly/growth?period=90d"
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /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 issue was mailed by the provider before Commune saw
        it.
      tags:
        - Metrics
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
        - $ref: "#/components/parameters/StatsSince"
        - $ref: "#/components/parameters/StatsUntil"
        - name: metric
          in: query
          required: true
          description: Which quantity to bucket. Exactly one per request.
          schema:
            $ref: "#/components/schemas/TimeseriesMetric"
        - name: interval
          in: query
          required: false
          description: |
            How wide each bucket is. Defaults to `day`. A week starts on Monday
            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"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: >
            curl
            "https://api.usecommune.com/newsletters/the-weekly/timeseries?metric=subscribers&interval=week"
            \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
  /articles/{article}/stats:
    parameters:
      - $ref: "#/components/parameters/CommuneVersion"
      - $ref: "#/components/parameters/ArticlePath"
    get:
      operationId: getArticleStats
      summary: Retrieve one article's performance
      description: |
        How one issue did on both sides at once: what the email did, and what
        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 issue Commune sent itself. For an issue
        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 issue 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.

        Creator scope. The public tallies on an article, which are `likes`,
        `comments` and `highlights`, live on the article itself and need no
        creator key.
      tags:
        - Metrics
      parameters:
        - $ref: "#/components/parameters/Expand"
        - $ref: "#/components/parameters/Fields"
      responses:
        "200":
          description: The article's email and community performance.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ArticlePerformance"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.usecommune.com/articles/k7Rm2xQp/stats \
              -H "Authorization: Bearer $COMMUNE_API_KEY" \
              -H "Commune-Version: 2026-08-26"
webhooks:
  article.published:
    post:
      operationId: onArticlePublished
      summary: Article published
      description: |
        An article became publicly readable on Commune. Two paths reach this
        state and both publish here, distinguished by `data.source`.

        `native_send`: a Commune-hosted issue finished sending. Its status moved
        from `sending` to `sent` and `posted_at` was stamped, which is what
        surfaces it in the feed.

        `import`: a post arrived from a connected ESP or an RSS feed. Feeds are
        polled on a schedule, so this fires without a creator having done
        anything at that moment.

        An article with a future `posted_at` is scheduled, not published, and
        does not fire this topic until it is actually live.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ArticlePublishedEvent"
            examples:
              nativeIssueSent:
                summary: A native issue finished sending and went live.
                value:
                  id: 018f2a8b-6c4b-7d2e-9f11-6a1c3d5e7b90
                  type: article.published
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T09:32:11Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    title: What newsletters get wrong about community
                    slug: what-newsletters-get-wrong-about-community
                    url: >-
                      https://example.com/p/what-newsletters-get-wrong-about-community
                    published_at: "2026-08-26T09:32:11Z"
                    source: native_send
                    audience_scoped: false
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  article.scheduled:
    post:
      operationId: onArticleScheduled
      summary: Article scheduled
      description: |
        A native issue was queued for a future send. Its status became
        `scheduled` and its `scheduled_for` is in the future.

        Cancelling a schedule is a real state change too, but is not one of the
        21 topics. A consumer that needs to know a schedule went away should
        reconcile against the REST resource.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ArticleScheduledEvent"
            examples:
              scheduledForTomorrow:
                value:
                  id: 018f2a90-1111-7000-8000-000000000001
                  type: article.scheduled
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T10:04:00Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    title: Issue 42
                    scheduled_for: "2026-08-27T08:00:00Z"
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  billing.subscription.updated:
    post:
      operationId: onBillingSubscriptionUpdated
      summary: Billing subscription state changed
      description: |
        The newsletter's own Commune subscription moved between billing states:
        trialing, active, past_due, canceled or trial_expired.

        Two things move it. Activity at the payment provider: a checkout
        completing, the subscription being created, updated or deleted, an
        invoice being paid or failing. And a daily pass that expires trials
        which have run out, which is why a `trial_expired` event can arrive with
        no creator action behind it.

        Named `billing.subscription.*` on purpose. In Commune a "subscriber" is
        a reader of a newsletter, so an unqualified `subscription.updated` would
        read as a reader event. This one is about the creator paying Commune.

        A state here gates publishing. Past due beyond the grace window,
        canceled, and an expired trial each refuse a send with 402. A newsletter
        that is trialing or past due is additionally capped on how many emails
        it may send in a day, and exceeding that refuses with 429 rather than
        402, so a consumer watching for payment problems should treat both codes
        as billing refusals.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BillingSubscriptionUpdatedEvent"
            examples:
              trialConvertedToActive:
                value:
                  id: 018f2a92-1111-7000-8000-000000000011
                  type: billing.subscription.updated
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T17:05:00Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    subscription_id: f5061728-394a-4b5c-96d7-e8f90a1b2c3d
                    plan: creator
                    status: active
                    previous_status: trialing
                    trial_ends_at: "2026-08-26T00:00:00Z"
                    current_period_start: "2026-08-26T17:05:00Z"
                    current_period_end: "2026-09-26T17:05:00Z"
                    cancel_at_period_end: false
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  delivery.bounced:
    post:
      operationId: onDeliveryBounced
      summary: Delivery bounced
      description: |
        The message could not be delivered. From the `email.bounced` ingest
        branch.

        This has a side effect on the subscriber: they are flipped to `bounced`
        so later sends skip them, which also emits `subscriber.unsubscribed`
        with `reason: bounced`. Expect both events for one bounce.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeliveryBouncedEvent"
            examples:
              bounced:
                summary: >-
                  Arrives together with subscriber.unsubscribed carrying reason
                  bounced.
                value:
                  id: 018f2a90-7777-7000-8000-000000000007
                  type: delivery.bounced
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T09:34:02Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    delivery_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    send_id: 9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
                    subscriber_id: 33445566-7788-4990-a1b2-c3d4e5f60718
                    email: gone@example.com
                    provider_message_id: 4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4
                    status: bounced
                    reason: The recipient address does not exist.
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  delivery.clicked:
    post:
      operationId: onDeliveryClicked
      summary: Delivery link clicked
      description: |
        The recipient clicked a tracked link. From the `email.clicked` ingest
        branch.

        Which link was clicked is deliberately absent from the payload. The
        ingest records only that a click happened and when the first one did, so
        Commune holds no link-level detail and this contract will not imply that
        it does.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeliveryClickedEvent"
            examples:
              clicked:
                value:
                  id: 018f2a90-6666-7000-8000-000000000006
                  type: delivery.clicked
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T10:12:30Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    delivery_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    send_id: 9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
                    subscriber_id: 33445566-7788-4990-a1b2-c3d4e5f60718
                    email: reader@example.com
                    provider_message_id: 4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4
                    status: clicked
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  delivery.complained:
    post:
      operationId: onDeliveryComplained
      summary: Delivery marked as spam
      description: |
        The recipient reported the message as spam. From the `email.complained`
        ingest branch.

        Treated as more severe than a bounce and never overridden: the
        subscriber is flipped to `complained` and must not be mailed again,
        which also emits `subscriber.unsubscribed` with `reason: complained`.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeliveryComplainedEvent"
            examples:
              complained:
                value:
                  id: 018f2a90-8888-7000-8000-000000000008
                  type: delivery.complained
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T11:02:19Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    delivery_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    send_id: 9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
                    subscriber_id: 33445566-7788-4990-a1b2-c3d4e5f60718
                    email: annoyed@example.com
                    provider_message_id: 4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4
                    status: complained
                    reason: Spam complaint
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  delivery.delivered:
    post:
      operationId: onDeliveryDelivered
      summary: Delivery accepted by the recipient server
      description: |
        The provider confirmed the message reached the recipient's mail server.
        From the `email.delivered` ingest branch.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeliveryDeliveredEvent"
            examples:
              delivered:
                value:
                  id: 018f2a90-4444-7000-8000-000000000004
                  type: delivery.delivered
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T09:32:48Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    delivery_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    send_id: 9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
                    subscriber_id: 33445566-7788-4990-a1b2-c3d4e5f60718
                    email: reader@example.com
                    provider_message_id: 4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4
                    status: delivered
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  delivery.opened:
    post:
      operationId: onDeliveryOpened
      summary: Delivery opened
      description: |
        The recipient opened the message. From the `email.opened` ingest branch.

        Fires on every reported open, but `opened_at` on the delivery row is
        stamped only on the first one, so the row keeps first-open semantics
        while the topic keeps every occurrence. Open tracking is a pixel and is
        unreliable by nature: privacy proxies inflate it and image-blocking
        clients suppress it.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeliveryOpenedEvent"
            examples:
              opened:
                value:
                  id: 018f2a90-5555-7000-8000-000000000005
                  type: delivery.opened
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T10:11:04Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    delivery_id: 1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    send_id: 9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
                    subscriber_id: 33445566-7788-4990-a1b2-c3d4e5f60718
                    email: reader@example.com
                    provider_message_id: 4ef9f2b1-0c33-4c1b-8f9a-77c2e5d1a3b4
                    status: opened
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  domain.verified:
    post:
      operationId: onDomainVerified
      summary: Custom website domain verified
      description: |
        A creator's custom website domain went live: its `verification_status`
        became `active`. That state means ownership was validated, the
        certificate was issued, and the hostname was confirmed to actually reach
        Commune. All three, so this is the point at which the domain serves the
        site rather than merely resolving.

        This is the rendering domain (a creator's own hostname serving their
        Commune site), not the email sending domain. That one is
        `sender.verified`.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DomainVerifiedEvent"
            examples:
              siteDomainActive:
                value:
                  id: 018f2a92-1010-7000-8000-000000000010
                  type: domain.verified
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T16:44:30Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    domain_id: e4f50617-2839-4a4b-b5c6-d7e8f90a1b2c
                    domain: blog.example.com
                    ssl_status: active
                    verified_at: "2026-08-26T16:44:30Z"
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  highlight.created:
    post:
      operationId: onHighlightCreated
      summary: Highlight created
      description: |
        A reader marked a passage of an article.

        Personal by design, and the payload keeps it that way. A highlight is
        never attributed to a named reader anywhere in Commune, so this event
        carries the same opaque `owner_key` the REST resource does and no
        `user_id`. It is the one topic whose payload cannot be resolved back to
        a person.

        Fires on a genuine insert only. The write is an upsert keyed on (user,
        article, start_offset, end_offset), so re-marking the same span is
        idempotent and silent. Attaching a chat message to an existing
        highlight, which is how a passage becomes a discussion, updates that row
        rather than inserting one, so it does not fire here either. What it does
        fire is `message.created` for the message itself.

        A highlight created together with its discussion carries
        `data.message_id` from the start. One created silently carries null, and
        never gains a value on this topic.

        Article visibility is enforced before the write, so a passage of a
        tag-scoped article can only be marked by someone in its audience.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HighlightCreatedEvent"
            examples:
              silentHighlight:
                summary: A personal mark with no discussion attached.
                value:
                  id: 018f2a92-3232-7000-8000-000000000032
                  type: highlight.created
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T19:11:27Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    highlight_id: d4e5f607-1829-4a31-b2c3-d4e5f6071829
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    quote: The moderation load is the product, not a tax on it.
                    start_offset: 4218
                    end_offset: 4271
                    owner_key: 4f2a9c1e7b3d6a05
                    message_id: null
                    created_at: "2026-08-26T19:11:27Z"
              highlightWithDiscussion:
                summary: >-
                  A passage quoted into the article's chat thread, so the
                  highlight carries the message it started.
                value:
                  id: 018f2a92-3333-7000-8000-000000000033
                  type: highlight.created
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T19:14:50Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    highlight_id: e5f60718-2930-4b42-c3d4-e5f607182930
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    quote: Community is a distribution channel that answers back.
                    start_offset: 812
                    end_offset: 860
                    owner_key: 9b1d0e6a3c4f27b8
                    message_id: c2d3e4f5-0617-4829-93a4-b5c6d7e8f90a
                    created_at: "2026-08-26T19:14:50Z"
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  import.completed:
    post:
      operationId: onImportCompleted
      summary: Import finished
      description: |
        A bulk ingest run finished. `data.kind` says which of the three it was.

        `articles`: an import a creator started against their connected provider
        finished the batch they picked. `imported_count` is the number that
        actually landed.

        `subscribers`: a pull of every active contact from the connected
        provider finished. An uploaded CSV ends the same way, with `source:
        csv`.

        `migration`: the newsletter was flipped onto native sending. That is the
        destructive step of the move off a provider, and after it Commune sends
        the newsletter itself.

        What this topic is for is invalidation. Every one of these runs writes
        many rows at once, and none of them fans out into per-row events: an
        import does not emit one `article.published` or one `subscriber.created`
        per row it touched, because a provider migration of fifty thousand
        contacts would bury a consumer. This is the single event that says the
        shape of the newsletter just changed underneath you, so refetch.

        Only a run that reached its end publishes here. Both import paths stream
        their progress and swallow per-row failures, so a partial run still
        finishes and still fires, with a lower `imported_count` and no separate
        count of what it dropped. A run whose connection died mid-stream
        produces nothing at all, and there is no `import.failed` to pair with
        this.

        There is no run identifier on this payload and no `/imports/{id}` to
        look one up in. An import is a streamed request that ends by writing its
        rows and closing the response: nothing persists a job row, so an id here
        would reference something no consumer could ever fetch. What identifies
        a run is the newsletter, the kind and the source, and the only thing to
        do with one is refetch.

        Every one of these is idempotent and safe to re-run, so a consumer
        should expect to see the same import kind more than once for one
        newsletter.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImportCompletedEvent"
            examples:
              subscribersPulledFromEsp:
                summary: >-
                  A provider pull that finished. `imported_count` is what
                  landed, which is smaller than the upstream list wherever a row
                  was already there.
                value:
                  id: 018f2a92-3434-7000-8000-000000000034
                  type: import.completed
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T20:03:41Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    kind: subscribers
                    source: kit
                    imported_count: 4820
                    started_at: "2026-08-26T19:58:12Z"
                    completed_at: "2026-08-26T20:03:41Z"
              articlesImported:
                summary: A batch of back issues pulled in from the provider.
                value:
                  id: 018f2a92-3535-7000-8000-000000000035
                  type: import.completed
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T20:21:09Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    kind: articles
                    source: kit
                    imported_count: 63
                    started_at: "2026-08-26T20:19:44Z"
                    completed_at: "2026-08-26T20:21:09Z"
              migrationFinalized:
                summary: >-
                  The newsletter now sends natively. Nothing was ingested, so
                  `imported_count` is null.
                value:
                  id: 018f2a92-3636-7000-8000-000000000036
                  type: import.completed
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T20:30:00Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    kind: migration
                    source: null
                    imported_count: null
                    started_at: null
                    completed_at: "2026-08-26T20:30:00Z"
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  message.created:
    post:
      operationId: onMessageCreated
      summary: Message created
      description: |
        A reply was posted inside a thread (`thread_level` 1 or 2). Replies
        inherit the placement of their thread and carry no visibility of their
        own.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MessageCreatedEvent"
            examples:
              reply:
                value:
                  id: 018f2a91-eeee-7000-8000-00000000000e
                  type: message.created
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T15:14:02Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    message_id: c2d3e4f5-0617-4829-93a4-b5c6d7e8f90a
                    short_id: p7w2rd
                    url: https://example.com/t/k3n8qz#p7w2rd
                    thread_id: b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9
                    parent_id: b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9
                    thread_level: 1
                    author:
                      user_id: usr_9Lp3Zr7tYb
                      username: dan
                      display_name: Dan Whitlock
                      avatar_url: null
                    content: >-
                      Same here. We ended up capping thread depth for that
                      reason.
                    created_at: "2026-08-26T15:14:02Z"
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  send.completed:
    post:
      operationId: onSendCompleted
      summary: Send completed
      description: |
        A send run finished handing every recipient to the email provider, and
        the run was stamped with the `completed_at` this payload carries.

        This is the dispatch milestone, not the delivery milestone. It says the
        provider accepted the messages; whether they landed in inboxes is what
        the `delivery.*` topics report, and those arrive later and one per
        recipient.

        Named for the send, not for the article. A send run is its own resource
        in the REST catalog (`/sends`), it carries its own id, and one article
        can have several across retries, so the topic sits in the send namespace
        alongside `send.failed` rather than under `article.*`. `data.article_id`
        is still there for anyone routing by article.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendCompletedEvent"
            examples:
              sendFinished:
                value:
                  id: 018f2a90-2222-7000-8000-000000000002
                  type: send.completed
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T09:32:11Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    send_id: 9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
                    started_at: "2026-08-26T09:28:40Z"
                    completed_at: "2026-08-26T09:32:11Z"
                    recipient_count: 540
                    sent_count: 538
                    failed_count: 2
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  send.failed:
    post:
      operationId: onSendFailed
      summary: Send failed
      description: |
        A send run could not complete and the article was parked in `failed`,
        alongside the human-readable `failure_reason` this payload carries.

        Refusals that happen before dispatch starts, such as an unverified
        sender or a billing gate, are rejected synchronously when the send is
        requested and never reach this topic. What lands here is a run that
        began and then broke, most commonly every delivery failing at the
        provider.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendFailedEvent"
            examples:
              everyDeliveryFailed:
                value:
                  id: 018f2a90-3333-7000-8000-000000000003
                  type: send.failed
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T09:33:02Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    send_id: 9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
                    failure_reason: Every delivery failed.
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  sender.verified:
    post:
      operationId: onSenderVerified
      summary: Sender verified
      description: |
        A sending identity passed verification and the newsletter can send from
        it: its `verification_status` became `verified`. Two things reach that
        state, a newly provisioned sender the provider already considered
        verified, and a check on an existing sender coming back clean.

        Load-bearing rather than cosmetic: sending from an unverified sender is
        refused with `sender_not_verified`, so this event is the signal that
        sending is unblocked.

        Commune re-checks sending identities in the background as well as when a
        creator asks it to, so treat a repeat of this event for one sender as
        normal rather than as a second, different verification. Failing
        verification is a real state too, but is not among the 21 topics.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SenderVerifiedEvent"
            examples:
              customDomainVerified:
                value:
                  id: 018f2a91-ffff-7000-8000-00000000000f
                  type: sender.verified
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T16:00:12Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    sender_id: d3e4f506-1728-4939-a4b5-c6d7e8f90a1b
                    from_email: hello@mail.example.com
                    from_name: The Example Letter
                    reply_to_email: null
                    domain: mail.example.com
                    kind: custom
                    is_default: true
                    verified_at: "2026-08-26T16:00:12Z"
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  subscriber.created:
    post:
      operationId: onSubscriberCreated
      summary: Subscriber created
      description: |
        Someone became a subscriber of the newsletter. Four paths land here:
        subscribing from inside the Commune app, finishing signup, accepting an
        invitation, and a CSV or ESP import.

        Only the import path records where they came from.
        `data.acquisition_source` carries the provider on that path and is null
        on the other three, so a null is the normal case for anyone who arrived
        through Commune itself rather than a sign that the origin was lost.

        Reactivation counts. Unsubscribing is a soft flip, not a delete, so a
        returning reader updates the existing row back to `subscribed` rather
        than inserting a new one. That still fires this topic, with
        `data.resubscribed: true`, because from a consumer's point of view an
        inactive contact just became active again.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubscriberCreatedEvent"
            examples:
              subscribedOnCommune:
                value:
                  id: 018f2a90-9999-7000-8000-000000000009
                  type: subscriber.created
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T12:20:05Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    subscriber_id: 33445566-7788-4990-a1b2-c3d4e5f60718
                    email: reader@example.com
                    status: subscribed
                    user_id: usr_2Nf8Kq1pWc
                    acquisition_source: null
                    resubscribed: false
                    created_at: "2026-08-26T12:20:05Z"
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  subscriber.status_changed:
    post:
      operationId: onSubscriberStatusChanged
      summary: Subscriber insight status changed
      description: |
        A reader crossed a boundary in the newsletter's engagement ladder:
        `reader` to `superfan`, or `engaged` to `dormant`. This is the topic
        that makes Insights actionable. The scores themselves are a number a
        dashboard draws; a crossing is the moment a CRM or a re-engagement
        automation has something to do.

        Fired by a scheduled scoring pass that recomputes the insight record of
        every scored reader. One event per reader whose `status` actually
        differs from the one already stored, so a pass that changes only the
        scores is silent.

        Because the pass is scheduled, `occurred_at` is when the crossing was
        computed and not when the reader acted. `data.last_action_at` is the
        reader's own clock and sits earlier than it by up to a full scoring
        interval. A consumer that wants to react while the moment is still warm
        should read that field rather than `occurred_at`.

        Status is a rank inside this newsletter, not an absolute score, so a
        reader can be moved by other readers. Someone who did nothing at all can
        still be demoted because the audience around them got busier. Two events
        that look like opposite movements can therefore arrive from one pass
        without either reader having changed their behaviour.

        Only readers with a Commune account are scored, since engagement is
        attributed to an account, so a subscriber the newsletter knows only as
        an address never produces this event.

        Not the same thing as `subscriber.unsubscribed`. That reports the
        subscription itself ending. This reports how engaged someone is while
        they are still subscribed, and `dormant` is a reader who went quiet, not
        a reader who left.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubscriberStatusChangedEvent"
            examples:
              promotedToSuperfan:
                value:
                  id: 018f2a91-dddd-7000-8000-00000000000d
                  type: subscriber.status_changed
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-27T03:15:00Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    subscriber_id: 33445566-7788-4990-a1b2-c3d4e5f60718
                    user_id: usr_2Nf8Kq1pWc
                    email: reader@example.com
                    previous_status: reader
                    status: superfan
                    direction: promoted
                    total_score: 412
                    velocity: rising
                    last_action_at: "2026-08-26T21:04:11Z"
              wentDormant:
                value:
                  id: 018f2a91-eeee-7000-8000-00000000000e
                  type: subscriber.status_changed
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-27T03:15:00Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    subscriber_id: 44556677-8899-4aa1-b2c3-d4e5f6071829
                    user_id: usr_7Zx3Lm9qRt
                    email: quiet@example.com
                    previous_status: engaged
                    status: dormant
                    direction: demoted
                    total_score: 88
                    velocity: cooling
                    last_action_at: "2026-07-02T09:12:40Z"
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  subscriber.tagged:
    post:
      operationId: onSubscriberTagged
      summary: Subscriber tag added or removed
      description: |
        A tag was added to or taken off a subscriber. `data.direction` says
        which: `assigned` when the tag was put on, `removed` when it was taken
        off.

        One topic for both directions on purpose. Membership of a segment
        changing is a single concept, and a consumer mirroring an audience wants
        every change to it, so splitting the directions would only make them
        subscribe twice to hear the whole story.

        Applying a tag is idempotent, so re-applying one the subscriber already
        has does not produce a second event. Tags matter beyond bookkeeping:
        they scope who an article is sent to and who can see it, so treat these
        as segment membership changes rather than as labels.

        Deleting the tag itself is a soft delete and is a different, unmodelled
        change: it retires the tag for everyone at once rather than removing one
        assignment, so it does not fan out into one event per subscriber here.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubscriberTaggedEvent"
            examples:
              tagAssigned:
                value:
                  id: 018f2a91-bbbb-7000-8000-00000000000b
                  type: subscriber.tagged
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T14:00:00Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    subscriber_id: 33445566-7788-4990-a1b2-c3d4e5f60718
                    email: reader@example.com
                    tag_id: aa11bb22-cc33-4d44-8e55-ff6677889900
                    tag_name: Founding member
                    direction: assigned
              tagRemoved:
                value:
                  id: 018f2a91-cccc-7000-8000-00000000000c
                  type: subscriber.tagged
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T14:05:00Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    subscriber_id: 33445566-7788-4990-a1b2-c3d4e5f60718
                    email: reader@example.com
                    tag_id: aa11bb22-cc33-4d44-8e55-ff6677889900
                    tag_name: Founding member
                    direction: removed
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  subscriber.unsubscribed:
    post:
      operationId: onSubscriberUnsubscribed
      summary: Subscriber unsubscribed
      description: |
        A subscriber stopped being mailable. `data.reason` says why.

        `self_service`: the reader opted out themselves, either through the
        unsubscribe link in an email or the RFC 8058 one-click header, or from
        inside the Commune app.

        `bounced` and `complained`: forced by delivery telemetry rather than
        chosen by the reader, so they arrive alongside `delivery.bounced` or
        `delivery.complained`.

        The row survives, it is only flipped, which is what preserves bounce and
        complaint history and lets a later resubscribe reuse it.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubscriberUnsubscribedEvent"
            examples:
              oneClickUnsubscribe:
                value:
                  id: 018f2a91-aaaa-7000-8000-00000000000a
                  type: subscriber.unsubscribed
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T13:41:52Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    subscriber_id: 33445566-7788-4990-a1b2-c3d4e5f60718
                    email: reader@example.com
                    reason: self_service
                    unsubscribed_at: "2026-08-26T13:41:52Z"
                    article_send_id: 9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  thread.created:
    post:
      operationId: onThreadCreated
      summary: Thread created
      description: |
        A new top-level thread was started in a newsletter's space
        (`thread_level = 0`).

        `data.visibility` decides where it appears: `public` puts it on the
        global feed and only team members may set it, `subscribers` keeps it
        inside the newsletter's own space. `paid` exists in the enum and is not
        in use yet.

        A thread created public fires `thread.published` from the same write, so
        a consumer that only cares about the feed can subscribe to that topic
        alone and ignore this one.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ThreadCreatedEvent"
            examples:
              articleDiscussion:
                summary: >-
                  A thread bound to an article, which is where comments live
                  now.
                value:
                  id: 018f2a91-dddd-7000-8000-00000000000d
                  type: thread.created
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T15:10:44Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    thread_id: b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9
                    short_id: k3n8qz
                    url: https://example.com/t/k3n8qz
                    author:
                      user_id: usr_2Nf8Kq1pWc
                      username: mira
                      display_name: Mira Okafor
                      avatar_url: https://cdn.example.com/avatars/mira.png
                    content: >-
                      The bit about moderation load matched my experience
                      exactly.
                    visibility: subscribers
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    created_at: "2026-08-26T15:10:44Z"
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
  thread.published:
    post:
      operationId: onThreadPublished
      summary: Thread published to the feed
      description: |
        A thread's `visibility` became `public`, which is what puts it on the
        global feed. Since posts were retired into chat, this is the single
        deliberate path by which community content leaves one newsletter's space
        and reaches everyone, so it is the topic to watch for anything that
        mirrors, syndicates or moderates the feed.

        Two code paths reach `public` and both publish here, distinguished by
        `data.source`.

        `visibility_changed`: a team member featured a thread that already
        existed. Who featured it and when are recorded by the same change, and
        reach this payload as `published_by` and `published_at`.

        `created_public`: the thread was born public, because a team member may
        set `visibility: 'public'` when starting a top-level thread. That thread
        fires `thread.created` and this topic from the same write, and has no
        `published_by`.

        Only team members (owner, admin or editor) can reach `public` on either
        path, and only a top-level thread can: featuring anything with a
        non-zero `thread_level` is refused, and replies inherit their thread's
        placement. Going the other way, back to `subscribers`, is a real change
        but is not modelled: a consumer mirroring the feed should reconcile
        against the REST resource rather than assume a thread it saw here is
        still public.

        Delivered as a single HTTPS POST to the consumer's registered endpoint,
        with the message as the JSON request body. Respond 2xx to acknowledge.
        Anything else, or a timeout, is retried with backoff, so acknowledge
        fast and do the work afterwards.
      tags:
        - Webhooks
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/WebhookVersion"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ThreadPublishedEvent"
            examples:
              featuredByTheTeam:
                summary: >-
                  An existing subscribers-only thread was featured onto the
                  feed.
                value:
                  id: 018f2a92-3030-7000-8000-000000000030
                  type: thread.published
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T18:02:19Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    thread_id: b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9
                    short_id: k3n8qz
                    url: https://example.com/t/k3n8qz
                    author:
                      user_id: usr_2Nf8Kq1pWc
                      username: mira
                      display_name: Mira Okafor
                      avatar_url: https://cdn.example.com/avatars/mira.png
                    visibility: public
                    previous_visibility: subscribers
                    source: visibility_changed
                    published_by:
                      user_id: usr_5Qw8Hn2vFd
                      username: sam
                      display_name: Sam Ortega
                      avatar_url: null
                    article_id: 4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f
                    published_at: "2026-08-26T18:02:19Z"
              startedPublic:
                summary: >-
                  A team member started the thread public, so this arrives
                  alongside thread.created from the same write.
                value:
                  id: 018f2a92-3131-7000-8000-000000000031
                  type: thread.published
                  api_version: "2026-08-26"
                  occurred_at: "2026-08-26T18:40:03Z"
                  newsletter_id: 7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411
                  actor: null
                  idempotency_key: null
                  data:
                    thread_id: c3d4e5f6-0718-4920-a1b2-c3d4e5f60718
                    short_id: t9m4hx
                    url: https://example.com/t/t9m4hx
                    author:
                      user_id: usr_5Qw8Hn2vFd
                      username: sam
                      display_name: Sam Ortega
                      avatar_url: null
                    visibility: public
                    previous_visibility: null
                    source: created_public
                    published_by: null
                    article_id: null
                    published_at: "2026-08-26T18:40:03Z"
      responses:
        "200":
          description: |
            The consumer accepted the delivery. Any 2xx acknowledges the message
            and it is not sent again.
        4XX:
          description: |
            The consumer rejected the delivery. Handled exactly like a 5xx: the
            message is retried with backoff, because a rejection cannot be told
            apart from a consumer that is briefly misconfigured.
        5XX:
          description: |
            The consumer failed to handle the delivery. Retried with backoff. A
            timeout is the same case and is retried too.
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: Commune API key
      description: |
        A Commune API key, sent as `Authorization: Bearer <key>`. Keys are
        issued per newsletter and carry either public or creator scope.
    webhookSignature:
      type: apiKey
      in: header
      name: Commune-Signature
      description: |
        An HMAC over the raw body of a webhook request, sent with the timestamp
        it was signed at in `Commune-Timestamp`. This is the credential Commune
        presents to a consumer, which is why every operation under `webhooks`
        declares it rather than inheriting the document level API key: that one
        travels the other way. Verify against the raw bytes before parsing the
        JSON, and reject a timestamp outside your tolerance window so a
        captured request cannot be replayed later.
  parameters:
    CommuneVersion:
      name: Commune-Version
      in: header
      required: false
      description: |
        The contract version this request is written against, as a release date
        (`YYYY-MM-DD`). 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. An unknown value answers `400` with
        `invalid_version`.
      schema:
        type: string
        format: date
        examples:
          - "2026-08-26"
    NewsletterPath:
      name: newsletter
      in: path
      required: true
      description: |
        The newsletter's `id` (a UUID) or its `handle`. A handle is unique
        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
    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
    SenderPath:
      name: sender
      in: path
      required: true
      description: The sending address's `id`. It has no short id.
      schema:
        type: string
        format: uuid
    DomainPath:
      name: domain
      in: path
      required: true
      description: |
        The website domain's `id`, or the hostname itself. The hostname is
        unique across Commune, so either resolves.
      schema:
        type: string
      examples:
        byHostname:
          summary: By hostname
          value: blog.example.org
    HighlightPath:
      name: highlight
      in: path
      required: true
      description: The highlight's `id`. A highlight has no short id.
      schema:
        type: string
        format: uuid
    TagPath:
      name: tag
      in: path
      required: true
      description: The tag's `id`. A tag has no short id.
      schema:
        type: string
        format: uuid
    SubscriberPath:
      name: subscriber
      in: path
      required: true
      description: |
        The subscriber's `id`. This identifies one person's membership of one
        newsletter, not the person, and a subscriber has no short id.
      schema:
        type: string
        format: uuid
    ThreadPath:
      name: thread
      in: path
      required: true
      description: |
        The thread's `id` (a UUID) or its `short_id`. A thread that Commune
        opened under an article has no `short_id`, because it is addressed on
        the web through the article's own permalink, so use its `id`.
      schema:
        type: string
      examples:
        byShortId:
          summary: By short id
          value: b3Xn8kTw
    MessagePath:
      name: message
      in: path
      required: true
      description: The message's `id` (a UUID) or its `short_id`.
      schema:
        type: string
    UserPath:
      name: user
      in: path
      required: true
      description: The user's `id` or their `username`, with or without a leading `@`.
      schema:
        type: string
      examples:
        byUsername:
          summary: By username
          value: "@ada"
    Cursor:
      name: cursor
      in: query
      required: false
      description: |
        The `pagination.next_cursor` value from the previous page. Omit it to
        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
    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`.

        One accepted path is not a relationship. `?expand=content` on
        `GET /articles/{article}` adds the Markdown rendition of the body
        beside the HTML one. It is the same trade the parameter always offers,
        a fuller response for a larger one, over a property that has more than
        one representation rather than over a reference.
      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`.

        A trimmed body is a subset of the schema this operation declares, and a
        property that schema marks required is absent when it was not asked for.
        That is the point of the parameter, so a client that validates responses
        against the schema either sends no `fields` or relaxes `required`.
      schema:
        type: string
      examples:
        trimmed:
          summary: Only the fields a link list needs
          value: title,slug,posted_at
    StatsPeriod:
      name: period
      in: query
      required: false
      description: |
        A named window, counted back from today in UTC. Defaults to `30d`.
        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
    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"
    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"
    WebhookVersion:
      name: Commune-Version
      in: header
      required: true
      description: >-
        API version the payload conforms to. Same value as `api_version` in the
        envelope.
      schema:
        type: string
        examples:
          - "2026-08-26"
    WebhookEventId:
      name: Commune-Event-Id
      in: header
      required: true
      description: Same value as `id` in the envelope. The dedupe key.
      schema:
        type: string
        format: uuid
    WebhookEventType:
      name: Commune-Event-Type
      in: header
      required: true
      description: Same value as `type` in the envelope. The topic name.
      schema:
        type: string
    WebhookSignature:
      name: Commune-Signature
      in: header
      required: true
      description: HMAC over the raw request body. Verify before parsing.
      schema:
        type: string
    WebhookTimestamp:
      name: Commune-Timestamp
      in: header
      required: true
      description: >-
        Unix seconds the signature was produced at. Reject anything outside your
        tolerance window.
      schema:
        type: string
    WebhookDeliveryAttempt:
      name: Commune-Delivery-Attempt
      in: header
      required: false
      description: >-
        1 on the first attempt, incremented on each retry. Informational only. A
        consumer must be idempotent regardless of its value.
      schema:
        type: integer
        minimum: 1
  responses:
    BadRequest:
      description: |
        The request was malformed: an unknown query parameter, an unparseable
        cursor, an unknown `expand` path, or an unrecognised `Commune-Version`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: |
        No API key was presented, or the key is unknown, revoked or expired.

        All four answer identically, down to the wording. Saying that a key was
        revoked rather than never issued confirms to whoever is holding the
        string that it was once real, which a legitimate caller does not need
        and a thief should not get.
      headers:
        WWW-Authenticate:
          description: |
            The authentication scheme this API accepts. Always
            `Bearer realm="Commune API"`; 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"
    Forbidden:
      description: |
        The key is valid but is not allowed to read this. Either it carries
        public scope and the operation needs creator scope, or it is bound to a
        different newsletter than the one addressed.
      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"
    PaymentRequired:
      description: |
        The key is allowed to read this but its plan does not include it.
        Insights is the one read surface Commune reserves the right to meter,
        because its numbers are computed rather than looked up: the scores and
        the event stream behind them are the expensive half of the catalog and
        the half no email provider can answer. Every other read stays free.
      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.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    InternalError:
      description: Something failed inside Commune. The request may be retried.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    Pagination:
      type: object
      title: Pagination
      description: |
        Cursor pagination state. Commune never exposes an offset or a page
        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
    ListEnvelope:
      type: object
      title: ListEnvelope
      description: |
        The envelope every collection is returned in. `data` holds the page,
        `pagination` holds the cursor state. Each list operation narrows `data`
        to the item type it returns.
      required:
        - object
        - data
        - pagination
      properties:
        object:
          type: string
          const: list
          description: Always `list`, so a response is self describing.
        data:
          type: array
          description: This page of items, in the order the operation documents.
          items: {}
        pagination:
          $ref: "#/components/schemas/Pagination"
    Ref:
      type: object
      title: Ref
      description: |
        An unexpanded relationship. Ask for the relationship in `?expand=` to
        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
    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 is deliberately redundant with `message`, which names the
                same values in prose. The array is what a program branches on
                and the sentence is what a model reads, and a caller correcting
                a request has to be able to do it from this one response
                without opening a reference page.

                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 no parameter at
                fault and `param` is absent, and this carries the scopes that
                would have been accepted 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 reference page for this error code.
              examples:
                - https://usecommune.com/docs/api/errors#not_found
    ErrorCode:
      type: string
      title: ErrorCode
      description: |
        The stable, machine readable reason a request failed. New codes may be
        added in a minor version, so treat an unrecognised code as a generic
        failure of its HTTP status class.
      enum:
        - bad_request
        - invalid_version
        - unauthorized
        - forbidden
        - insufficient_scope
        - payment_required
        - not_found
        - conflict
        - unprocessable
        - rate_limited
        - internal_error
    DependencyState:
      type: string
      title: DependencyState
      description: |
        How a single dependency answered its last check. `up` is a successful
        answer, `degraded` is an answer that arrived but was slow or partial,
        and `down` is no usable answer at all.

        `down` covers every way a dependency can be unavailable to this
        deployment and does not distinguish between them. Read it as "not
        usable right now", never as a statement about why.
      enum:
        - up
        - degraded
        - down
    Dependency:
      type: object
      title: Dependency
      description: |
        One capability the API depends on, and how it answered. `required` is
        the field that matters when deciding what to do about a failure: a
        required dependency being down means no operation can be served, while
        an optional one being down costs only the operations that touch it.

        A dependency is named by the capability it provides, never by the
        vendor providing it, and carries no free text. `getStatus` is
        unauthenticated, so its body is held to answering whether the API can
        serve. Anything finer, including why a dependency is `down`, is an
        operator concern and is not published here.
      additionalProperties: false
      required:
        - name
        - state
        - required
        - checked_at
      properties:
        name:
          type: string
          description: |
            Which capability this is. Stable across versions, so it is safe to
            branch on: the name says what the dependency does, not who provides
            it, so changing a provider does not change the name.
          enum:
            - database
            - email_service
            - payments
            - edge
        state:
          $ref: "#/components/schemas/DependencyState"
        required:
          type: boolean
          description: |
            Whether the API can serve at all without it. The database is
            required. `email_service`, `payments` and `edge` are not: they back
            sending, billing and domain provisioning, none of which the current
            read-only operations touch, so losing one narrows the surface
            rather than closing it.
        checked_at:
          type: string
          format: date-time
          description: |
            When this dependency was last checked. Checks are cached for a few
            seconds, so this is usually a little behind the request. Always
            present, whatever the state.
    RateLimitPolicy:
      type: object
      title: RateLimitPolicy
      description: |
        One budget a key is measured against. A request is charged to every
        budget that applies to its operation, and has to pass all of them.
      additionalProperties: false
      required:
        - name
        - limit
        - used
        - remaining
        - window_seconds
        - reset_at
        - description
      properties:
        name:
          type: string
          enum:
            - general
            - audience
          description: |
            `general` counts every request made with the key. `audience` counts
            only the operations that return subscriber or recipient email
            addresses, and is the tighter of the two.
        limit:
          type: integer
          minimum: 1
          description: Requests this budget allows per window.
        used:
          type: integer
          minimum: 0
          description: >
            Requests counted in the current window, including the one that
            asked.
        remaining:
          type: integer
          minimum: 0
          description: |
            Requests left in the current window. Zero means the next request
            charged to this budget answers `429`.
        window_seconds:
          type: integer
          minimum: 1
          description: |
            How long a window lasts. A window is anchored to the first request
            that opened it rather than to the clock, so it does not reset on the
            minute.
        reset_at:
          type: string
          format: date-time
          description: |
            When this budget's window resets and `used` returns to zero. The
            same instant `RateLimit-Reset` reports as a number of seconds.
        description:
          type: string
          description: What this budget counts, in one line.
    RateLimit:
      type: object
      title: RateLimit
      description: |
        A key's rate limit state. The top level fields repeat the `general`
        budget, which every request is charged to; `policies` carries every
        budget, which is what a client should read before deciding it has been
        cut off.
      additionalProperties: false
      required:
        - object
        - limit
        - remaining
        - window_seconds
        - reset_at
        - policies
      properties:
        object:
          type: string
          const: rate_limit
          description: Always `rate_limit`.
        limit:
          type: integer
          minimum: 1
          description: The `general` budget's limit, repeated for convenience.
        remaining:
          type: integer
          minimum: 0
          description: Requests left on the `general` budget in this window.
        window_seconds:
          type: integer
          minimum: 1
          description: The `general` budget's window length.
        reset_at:
          type: string
          format: date-time
          description: When the `general` budget's window resets.
        policies:
          type: array
          description: |
            Every budget this key is measured against, `general` first. A budget
            that does not apply to any operation the key may call is still
            listed, because what it counts is a property of the API rather than
            of the key.
          items:
            $ref: "#/components/schemas/RateLimitPolicy"
    ServiceStatus:
      type: object
      title: ServiceStatus
      description: |
        The service's own health, the state of what it depends on, and the
        contract version it is currently serving.
      additionalProperties: false
      required:
        - object
        - status
        - version
        - dependencies
      properties:
        object:
          type: string
          const: service_status
          description: Always `service_status`.
        status:
          type: string
          description: |
            The whole service in one word, driven by the required dependencies
            alone. `ok` when every required dependency is up, `degraded` when
            one answered slowly or partially, and `down` when one is
            unreachable. A `degraded` service still answers `200` here, because
            the point of this operation is to say so.

            An optional dependency being `down` does not move this. Every
            operation in the current contract is a read served out of the
            database, so none of them depends on one. Read `dependencies` for
            the individual states.
          enum:
            - ok
            - degraded
            - down
        version:
          type: string
          format: date
          description: |
            The newest contract version this service serves, as a release date.
            A request that sends no `Commune-Version` header is not
            necessarily on it: an existing key stays pinned to the version that
            was current when it was issued. Compare the two to find out whether
            an integration has a newer contract available to move to.
          examples:
            - "2026-08-26"
        dependencies:
          type: array
          description: |
            Every dependency the API checks, whatever its state. The set is
            fixed by this contract rather than by the deployment, so the list
            is the same length on every response from every environment. Order
            is not meaningful; match on `name`.
          items:
            $ref: "#/components/schemas/Dependency"
    Esp:
      type: string
      title: Esp
      description: |
        Where a newsletter is published from. `native` 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:
        - native
        - beehiiv
        - buttondown
        - ghost
        - kit
        - mailchimp
        - mailerlite
        - rss
        - substack
    SocialLinks:
      type: object
      title: SocialLinks
      description: |
        The creator's other homes on the internet, stored as canonical profile
        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.
    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.
        cover_url:
          type:
            - string
            - "null"
          format: uri
          description: Wide cover image shown at the top of the profile.
        accent_color:
          type:
            - string
            - "null"
          description: |
            The creator's brand colour as a CSS hex triplet, used to tint the
            profile. Null when they never picked one.
          examples:
            - "#4f46e5"
        website_url:
          type:
            - string
            - "null"
          format: uri
          description: The creator's own site, if they linked one.
        social_links:
          $ref: "#/components/schemas/SocialLinks"
        language:
          type:
            - string
            - "null"
          description: |
            Best known language of the newsletter's writing as a BCP 47 tag.
            Detected from recent issues 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. Read only here:
            the API does not create threads.
        allow_non_subscriber_chat:
          type: boolean
          description: |
            Whether people who have not subscribed may reply in existing
            threads.
        owner:
          description: |
            The account that owns the newsletter. A `Ref` unless `owner` is
            named in `?expand=`.
          anyOf:
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/User"
        featured_article:
          description: |
            The article the creator pinned to the top of the profile, or `null`
            when none is pinned. A `Ref` unless `featured_article` is named in
            `?expand=`.
          oneOf:
            - type: "null"
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Article"
        created_at:
          type: string
          format: date-time
          description: When the newsletter was connected to or created on Commune.
        updated_at:
          type: string
          format: date-time
          description: When the profile last changed.
    ArticleStatus:
      type: string
      title: ArticleStatus
      description: |
        Where an issue is in its life. Only `sent` is ever visible to a public
        key. An imported article is always `sent`, since Commune sees it after
        the provider delivered it.
      enum:
        - draft
        - scheduled
        - sending
        - sent
        - failed
        - archived
    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.
    Article:
      type: object
      title: Article
      description: |
        One issue 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 issue.
        newsletter:
          description: |
            The newsletter this issue 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 issue. 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 issue.
        external_url:
          type:
            - string
            - "null"
          format: uri
          description: |
            The issue's canonical URL on the newsletter's own provider, for an
            imported article. Null for a natively published one.
        status:
          $ref: "#/components/schemas/ArticleStatus"
        is_imported:
          type: boolean
          description: |
            `true` when the issue came in from the newsletter's provider,
            `false` when it was written and sent natively in Commune.
        posted_at:
          type:
            - string
            - "null"
          format: date-time
          description: |
            When the issue 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.
        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 issue opened, where its discussion lives.
            `null` when the newsletter does not open a thread per issue. 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 issue was last edited.
    ArticleWithContent:
      title: ArticleWithContent
      description: |
        An article including its rendered body. Returned only by
        `GET /articles/{article}`. `content` cannot be requested on any list,
        including through `?fields=`.
      allOf:
        - $ref: "#/components/schemas/Article"
        - type: object
          required:
            - content
          properties:
            content:
              type: string
              description: |
                The body as HTML. For a natively published issue this is the
                email rendered for the web, with personalization placeholders
                resolved against an empty context so no raw merge tag is ever
                served. For an imported issue it is what the provider published.

                Treat it as untrusted markup from a third party and render it in
                a sandboxed context.
            content_markdown:
              type:
                - string
                - "null"
              description: |
                The same body as Markdown, present only when `content` is named
                in `?expand=`. It is what a model should read: the HTML is
                mostly markup it will not use, and one issue body can fill a
                context window on its own.

                It is a conversion of the body rather than of the HTML above.
                For a natively published issue it comes from the document the
                author wrote, so a code block keeps its language and a table
                that declares a header becomes a Markdown table. For an
                imported issue it comes from the provider's HTML. Either way
                the words, the links, the images, the lists, the code and the
                quotes survive, and everything presentational does not.

                `null` means Commune holds no body it can convert faithfully.
                That happens when the only body it stored is a rendered email,
                whose words cannot be told apart from its layout. An empty
                string means the issue has no body, which is different.

                No merge tag ever appears here, resolved or not.
      unevaluatedProperties: false
    DnsRecord:
      type: object
      title: DnsRecord
      description: |
        A record the creator has to publish in their own DNS before Commune can
        send from an address or serve a domain. These are public by nature: they
        end up in a zone anyone can query.
      additionalProperties: false
      required:
        - type
        - name
        - value
      properties:
        type:
          type: string
          description: The record type.
          examples:
            - CNAME
        name:
          type: string
          description: The host the record goes on.
          examples:
            - mail._domainkey.example.org
        value:
          type: string
          description: What the record must resolve to.
        priority:
          type:
            - integer
            - "null"
          description: Priority, for a record type that takes one. Null otherwise.
        status:
          type:
            - string
            - "null"
          description: |
            What Commune last observed for this record, when it checked it
            individually. Null when only the address as a whole was checked.
    SenderKind:
      type: string
      title: SenderKind
      description: |
        `native` is an address Commune provisioned on a domain it owns, which
        works without the creator touching DNS. `custom` is an address on the
        creator's own domain, which does not work until they publish the
        records.
      enum:
        - native
        - custom
    SenderVerificationStatus:
      type: string
      title: SenderVerificationStatus
      description: |
        How far along the address is. Only `verified` can send. `provisioning`
        means Commune is still setting it up and the creator has nothing to do
        yet.
      enum:
        - provisioning
        - pending
        - verified
        - failed
    Sender:
      type: object
      title: Sender
      description: |
        An address a newsletter sends from. Nothing about the underlying email
        infrastructure is exposed: the provider's own identifiers for the domain
        stay server side, because they are an implementation detail Commune
        reserves the right to change.
      additionalProperties: false
      required:
        - object
        - id
        - newsletter
        - kind
        - from_email
        - verification_status
        - is_default
        - created_at
      properties:
        object:
          type: string
          const: sender
          description: Always `sender`.
        id:
          type: string
          format: uuid
          description: Stable identifier.
        newsletter:
          description: |
            The newsletter that sends from this address. A `Ref` unless
            `newsletter` is named in `?expand=`.
          oneOf:
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Newsletter"
        kind:
          $ref: "#/components/schemas/SenderKind"
        from_email:
          type: string
          format: email
          description: The full address issues are sent from.
        from_name:
          type:
            - string
            - "null"
          description: The display name shown beside the address in an inbox.
        reply_to_email:
          type:
            - string
            - "null"
          format: email
          description: |
            Where replies go, when the creator wants them somewhere other than
            `from_email`.
        domain:
          type: string
          description: The domain part of the address.
        local_part:
          type: string
          description: The part before the at sign.
        verification_status:
          $ref: "#/components/schemas/SenderVerificationStatus"
        verification_records:
          type: array
          description: |
            The DNS records the creator must publish. Empty for a `native`
            address, where Commune owns the domain and has already done it.
          items:
            $ref: "#/components/schemas/DnsRecord"
        verification_error:
          type:
            - string
            - "null"
          description: |
            Why verification last failed, in a form the creator can act on. Null
            when it has not failed.
        is_default:
          type: boolean
          description: |
            Whether this is the address the newsletter sends from unless told
            otherwise. Exactly one address per newsletter has this.
        verified_at:
          type:
            - string
            - "null"
          format: date-time
          description: When the address was first verified. Null if it never was.
        last_checked_at:
          type:
            - string
            - "null"
          format: date-time
          description: |
            When Commune last looked at the DNS. Verification is re checked on a
            schedule, so this moves without the creator doing anything.
        created_at:
          type: string
          format: date-time
          description: When the address was added.
        updated_at:
          type: string
          format: date-time
          description: When the row last changed.
    DomainVerificationStatus:
      type: string
      title: DomainVerificationStatus
      description: |
        Whether ownership of the domain was proved and a certificate issued.
        `active` does not by itself mean the site is reachable, see
        `routing_ok`.
      enum:
        - pending
        - active
        - failed
    Domain:
      type: object
      title: Domain
      description: |
        A creator's own domain serving their Commune site. The certificate
        provider's internal identifier for it is not exposed.
      additionalProperties: false
      required:
        - object
        - id
        - newsletter
        - domain
        - verification_status
        - created_at
      properties:
        object:
          type: string
          const: domain
          description: Always `domain`.
        id:
          type: string
          format: uuid
          description: Stable identifier.
        newsletter:
          description: |
            The newsletter the domain serves. A `Ref` unless `newsletter` is
            named in `?expand=`.
          oneOf:
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Newsletter"
        domain:
          type: string
          description: The hostname, unique across Commune.
          examples:
            - blog.example.org
        verification_status:
          $ref: "#/components/schemas/DomainVerificationStatus"
        ssl_status:
          type:
            - string
            - "null"
          description: |
            Where the certificate stands, as reported by the provider that
            issues it. Free text rather than an enum, because the vocabulary is
            the provider's and Commune passes it through.
        dns_target:
          type:
            - string
            - "null"
          description: What the creator must point the hostname at.
        verification_records:
          type: array
          description: The records the creator must publish to prove ownership.
          items:
            $ref: "#/components/schemas/DnsRecord"
        verification_error:
          type:
            - string
            - "null"
          description: Why verification last failed. Null when it has not failed.
        routing_ok:
          type:
            - boolean
            - "null"
          description: |
            Whether the domain actually resolves to Commune, from an end to end
            request rather than from certificate state. Null before the first
            probe. This is the field that catches a domain whose certificate is
            fine but whose DNS points somewhere else.
        routing_checked_at:
          type:
            - string
            - "null"
          format: date-time
          description: When that probe last ran.
        verified_at:
          type:
            - string
            - "null"
          format: date-time
          description: When ownership was first proved. Null if it never was.
        last_checked_at:
          type:
            - string
            - "null"
          format: date-time
          description: When Commune last reconciled the domain with its provider.
        created_at:
          type: string
          format: date-time
          description: When the domain was added.
    SearchResultType:
      type: string
      title: SearchResultType
      description: |
        What a search result points at. There is no post or comment kind:
        Commune retired both into chat, so a discussion result is a `thread` or
        a `message`.
      enum:
        - newsletter
        - article
        - user
        - thread
        - message
    SearchResult:
      type: object
      title: SearchResult
      description: |
        One hit. It always carries enough to render a row without a second
        request, and points at the full resource through `resource`.
      additionalProperties: false
      required:
        - object
        - type
        - resource
      properties:
        object:
          type: string
          const: search_result
          description: Always `search_result`.
        type:
          $ref: "#/components/schemas/SearchResultType"
        title:
          type:
            - string
            - "null"
          description: |
            The heading for the row: a newsletter's name, an article's title, a
            person's display name. Null for a chat hit, which has no title.
        snippet:
          type:
            - string
            - "null"
          description: |
            A fragment of the matching text with the query terms in it, as plain
            text. Null when the match was on the title alone.
        url:
          type: string
          format: uri
          description: Where the hit lives on the web.
        score:
          type: number
          minimum: 0
          description: |
            How well the hit matched, higher being better. Results come back
            ordered by it, so it is there to compare hits within one response
            and to draw a relevance cutoff. It is not comparable across
            queries or across contract versions, and the scale is not
            documented because the ranking is still being tuned.
        resource:
          description: |
            The object itself. A `Ref` unless `resource` is named in `?expand=`,
            in which case it is the full object of the kind named by `type`.
          anyOf:
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Newsletter"
            - $ref: "#/components/schemas/Article"
            - $ref: "#/components/schemas/User"
            - $ref: "#/components/schemas/Thread"
            - $ref: "#/components/schemas/Message"
    Highlight:
      type: object
      title: Highlight
      description: |
        A passage of an article a reader marked. Highlights are the anchor for
        an inline comment, which is why one can carry a link to the message it
        started.
      additionalProperties: false
      required:
        - object
        - id
        - article
        - quote
        - start_offset
        - end_offset
        - owner_key
        - created_at
      properties:
        object:
          type: string
          const: highlight
          description: Always `highlight`.
        id:
          type: string
          format: uuid
          description: Stable identifier.
        article:
          description: |
            The article the passage is in. A `Ref` unless `article` is named in
            `?expand=`.
          oneOf:
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Article"
        quote:
          type: string
          maxLength: 5000
          description: The marked text itself, as plain text.
        prefix:
          type: string
          description: |
            Up to two hundred characters of the text immediately before the
            quote. Together with `suffix` this re anchors the highlight when the
            body changed and the offsets no longer line up.
        suffix:
          type: string
          description: >-
            Up to two hundred characters of the text immediately after the
            quote.
        start_offset:
          type: integer
          minimum: 0
          description: |
            Where the passage starts, as a character offset into the article's
            plain text. Always less than `end_offset`.
        end_offset:
          type: integer
          minimum: 1
          description: Where the passage ends, as a character offset into the plain text.
        owner_key:
          type: string
          description: |
            An opaque, stable per highlighter value, scoped to this one article.
            Group by it to tell one reader's marks apart from another's, and
            count distinct values for a distinct highlighter count. It cannot be
            resolved to a person and does not correlate across articles: Commune
            does not attribute a highlight to a named reader.
          examples:
            - 4f2a9c1e7b3d6a05
        message:
          description: |
            The chat message the reader wrote from this passage, when they wrote
            one. `null` otherwise. A `Ref` unless `message` is named in
            `?expand=`.
          oneOf:
            - type: "null"
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Message"
        created_at:
          type: string
          format: date-time
          description: When the passage was marked.
    Tag:
      type: object
      title: Tag
      description: |
        A segment of a newsletter's audience. A tag is what makes an issue
        audience scoped: sending to a tag stamps the issue, 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 issue is
            still addressed to it. Retired tags keep their assignments, because
            the audience of a sent issue 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.
    MemberRole:
      type: string
      title: MemberRole
      description: |
        What someone may do on behalf of a newsletter. `owner` is not a stored
        membership: it is the account the newsletter belongs to, surfaced here
        as a role so the team reads as one list.
      enum:
        - owner
        - admin
        - editor
        - guest
    Member:
      type: object
      title: Member
      description: A person's place on a newsletter's team.
      additionalProperties: false
      required:
        - object
        - id
        - newsletter
        - user
        - role
      properties:
        object:
          type: string
          const: member
          description: Always `member`.
        id:
          type: string
          description: |
            Stable identifier for the membership. For the owner, who has no
            membership row, this is the newsletter's `id` prefixed with
            `owner_`.
        newsletter:
          description: |
            The newsletter. A `Ref` unless `newsletter` is named in `?expand=`.
          oneOf:
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Newsletter"
        user:
          description: |
            The person. A `Ref` unless `user` is named in `?expand=`.
          anyOf:
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/User"
        role:
          $ref: "#/components/schemas/MemberRole"
        created_at:
          type:
            - string
            - "null"
          format: date-time
          description: |
            When the person joined the team. Null for the owner, whose place
            predates the team.
    SubscriberStatus:
      type: string
      title: SubscriberStatus
      description: |
        Where a subscription stands. Source of truth for a newsletter Commune
        sends natively. 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 row, including one that has a Commune account behind it,
            because reconciling a list against the provider that holds it is
            the job this collection exists for and the people who joined
            through Commune are exactly the ones that provider has never heard
            of.

            For a row with no account the address is the one the person
            subscribed with. For a row with an account it is the address on
            that account, which is where the issue is actually delivered.
            Never null: a subscription is either an address or an account, and
            an account always carries one.

            This is the one place in the API an address appears. It is a
            property of the subscription and not of the person, so it is never
            on a public profile, never on an expanded author, and never
            reachable from a key bound to another newsletter. Reading it needs
            creator scope, counts against a tighter rate limit budget than
            everything else, and writes a row to Commune's audit log naming the
            key and how many subscribers it read.
        status:
          $ref: "#/components/schemas/SubscriberStatus"
        source:
          type: string
          enum:
            - commune
            - imported
          description: |
            `commune` when the person subscribed through Commune, `imported`
            when they came from the newsletter's provider or a file.
        tags:
          type: array
          description: |
            The audience tags this subscriber holds, which is what decides which
            tag scoped issues 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 native newsletter, which has no provider to reconcile
            with.
    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
    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 older attachment that predates the field.
        thumbnail:
          type:
            - string
            - "null"
          format: uri
          description: A smaller rendition, when one was generated.
    Thread:
      type: object
      title: Thread
      description: |
        A conversation in a newsletter's community, together with the message
        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"
        is_pinned:
          type: boolean
          description: Whether the team pinned this thread to the top of the community.
        is_locked:
          type: boolean
          description: Whether the team closed the thread to new replies.
        reply_count:
          type: integer
          minimum: 0
          description: Undeleted replies in the thread, at any depth.
        view_count:
          type: integer
          minimum: 0
          description: How many times the thread was opened.
        created_at:
          type: string
          format: date-time
          description: When the thread was opened.
        updated_at:
          type: string
          format: date-time
          description: When the thread row last changed for any reason.
        edited_at:
          type:
            - string
            - "null"
          format: date-time
          description: |
            When the author last edited the opening message. Null when it was
            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.
    Message:
      type: object
      title: Message
      description: |
        A reply inside a thread. Commune allows two levels: a reply to the
        thread, and a reply to that reply. A deleted message is omitted from
        every read rather than returned as a tombstone.
      additionalProperties: false
      required:
        - object
        - id
        - thread
        - newsletter
        - content
        - depth
        - created_at
      properties:
        object:
          type: string
          const: message
          description: Always `message`.
        id:
          type: string
          format: uuid
          description: Stable identifier.
        short_id:
          type:
            - string
            - "null"
          description: |
            Eight character base62 identifier used by the message's permalink.
            Null for a row that predates short ids.
        thread:
          description: |
            The thread this reply belongs to. A `Ref` unless `thread` is named
            in `?expand=`.
          oneOf:
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Thread"
        newsletter:
          description: |
            The community the thread lives in, denormalised so a client does not
            have to walk up to it. A `Ref` unless `newsletter` is named in
            `?expand=`.
          oneOf:
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Newsletter"
        author:
          description: |
            Who wrote the reply. A `Ref` unless `author` is named in `?expand=`.
          anyOf:
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/User"
        parent:
          description: |
            The message this one replies to, or `null` when it replies to the
            thread itself. A `Ref` unless `parent` is named in `?expand=`.
          oneOf:
            - type: "null"
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Message"
        quoted:
          description: |
            The message this one quotes, when the author quoted rather than
            replied. `null` otherwise. A `Ref` unless `quoted` is named in
            `?expand=`.
          oneOf:
            - type: "null"
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Message"
        depth:
          type: integer
          minimum: 1
          maximum: 2
          description: |
            `1` for a reply to the thread, `2` for a reply to a reply. Commune
            does not nest deeper, so a client can render the tree with a fixed
            two level layout.
        content:
          type: string
          description: |
            The message body as HTML. Treat it as untrusted markup and render it
            in a sandboxed context.
        media:
          type: array
          description: Attachments on the message.
          items:
            $ref: "#/components/schemas/Media"
        highlight:
          description: |
            The passage of an article this reply is anchored to, when the reader
            wrote it from a highlight. `null` otherwise. A `Ref` unless
            `highlight` is named in `?expand=`.
          oneOf:
            - type: "null"
            - $ref: "#/components/schemas/Ref"
            - $ref: "#/components/schemas/Highlight"
        created_at:
          type: string
          format: date-time
          description: When the reply was written.
        updated_at:
          type: string
          format: date-time
          description: When the row last changed for any reason.
        edited_at:
          type:
            - string
            - "null"
          format: date-time
          description: |
            When the author last edited the text or attachments. Not touched by
            reactions or other side effects, so it is a faithful edited marker.
    Reaction:
      type: object
      title: Reaction
      description: |
        One emoji on one message, aggregated across everyone who left it. There
        is no per person reaction resource: the interesting object is the emoji
        and its tally.
      additionalProperties: false
      required:
        - object
        - emoji
        - count
      properties:
        object:
          type: string
          const: reaction
          description: Always `reaction`.
        emoji:
          type: string
          description: The emoji itself, as the character rather than a shortcode.
          examples:
            - 🎉
        count:
          type: integer
          minimum: 1
          description: How many people left this emoji on the message.
        users:
          type: array
          description: |
            Who left it, in the order they did. Each entry is a `Ref` unless
            `users` is named in `?expand=`. Truncated for a heavily reacted
            message, in which case `count` is still the full tally.
          items:
            anyOf:
              - $ref: "#/components/schemas/Ref"
              - $ref: "#/components/schemas/User"
    User:
      type: object
      title: User
      description: |
        A person's public profile. This is the entire shape Commune will ever
        return for someone who is not the key holder. Email address, theme,
        notification preferences, push subscriptions, read state and saved
        articles are deliberately absent.
      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.
    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
    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
    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=`. 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 newsletter
            Commune sends natively, 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.
    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
    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
    EngagementEvent:
      type: object
      title: EngagementEvent
      description: |
        One scored action by one reader. These rows are what the subscriber
        insight scores are summed from, exposed unaggregated so a consumer can
        build its own model instead of accepting Commune's.

        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 on purpose,
            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.
    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
            newsletter Commune sends natively.
          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 issues, never counts of emails.
          required:
            - sent
            - scheduled
          properties:
            sent:
              type: integer
              minimum: 0
              description: |
                Issues that went out inside the window and that Commune has a
                record of. Commune's article table is filtered rather than
                exhaustive, so this must never be presented as the number of
                emails the newsletter sent. That number lives on a send.
            scheduled:
              type: integer
              minimum: 0
              description: |
                Issues queued to go out after `period_end`. A forward looking
                count, so it does not move when the window does.
        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.
          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 issues 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 issue.
          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.
    NewsletterGrowth:
      type: object
      title: NewsletterGrowth
      description: |
        Where a newsletter's new subscribers came from inside one window, and
        how Commune's own invitations to the list performed.
      additionalProperties: false
      required:
        - object
        - newsletter
        - period_start
        - period_end
        - by_source
        - invites
      properties:
        object:
          type: string
          const: newsletter_growth
          description: Always `newsletter_growth`.
        newsletter:
          description: |
            The newsletter that grew. A `Ref` unless `newsletter` is named in
            `?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: |
                  How these people arrived. `commune` means they joined through
                  Commune itself. Any other value names the provider or the file
                  they came from. `null` for rows written before Commune
                  recorded a source, which cannot be attributed after the fact.
                examples:
                  - commune
              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.
        invites:
          type: object
          additionalProperties: false
          description: |
            Commune's invitations to the people on the list who do not have a
            Commune account yet, asking them to join the conversation around the
            newsletter they already read.

            This funnel is ring fenced from the subscription. Declining an
            invitation leaves the newsletter subscription untouched, so nothing
            here is an unsubscribe signal.
          required:
            - sent
            - accepted
            - opted_out
          properties:
            sent:
              type: integer
              minimum: 0
              description: Invitations Commune sent inside the window.
            accepted:
              type: integer
              minimum: 0
              description: |
                Invited people who created a Commune account inside the window.
                Attributed to the window they accepted in, not the one they were
                invited in, so the two numbers describe different cohorts and
                dividing one by the other is not a conversion rate.
            opted_out:
              type: integer
              minimum: 0
              description: |
                Invited people who asked for no further invitations inside the
                window. They still receive the newsletter.
    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
    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
    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.
    ArticlePerformance:
      type: object
      title: ArticlePerformance
      description: |
        One issue measured on both sides at once: what the email did, and what
        the community did with it afterwards.

        Named for what it answers rather than for its path, because
        `ArticleStats` is already the small public tally that hangs off an
        article itself. This is the creator scoped report, and the two are not
        interchangeable.
      additionalProperties: false
      required:
        - object
        - article
        - email
        - community
      properties:
        object:
          type: string
          const: article_stats
          description: Always `article_stats`.
        article:
          description: |
            The issue measured. A `Ref` unless `article` is named in `?expand=`.
          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 issue 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 issue 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 issue, 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 issue on Commune, counted once each rather
                than once per visit.
            likes:
              type: integer
              minimum: 0
              description: People who liked the issue.
            saves:
              type: integer
              minimum: 0
              description: |
                People who put the issue in their own reading list. Who they are
                stays private.
            highlights:
              type: integer
              minimum: 0
              description: |
                Passages readers marked inside the body. The sentences worth
                reading before writing the next issue.
            thread_messages:
              type: integer
              minimum: 0
              description: |
                Replies in the issue's discussion. Commune has no separate
                comments store: an issue's discussion is a chat thread like any
                other, so this counts the undeleted replies hanging off it, and
                it is `0` for an issue 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 issue started a
                conversation or an argument between two people.
    Actor:
      type:
        - object
        - "null"
      description: >-
        Who caused the change. ALWAYS `null` today and consumers must treat it
        that way: the public API is currently read-only, so nothing that reaches
        this catalog was triggered through it, and Commune does not backfill an
        actor for changes made in the product UI or by a cron. The field exists
        now so that populating it, once writes exist, is an additive change
        rather than a new envelope version.
      additionalProperties: false
      required:
        - type
        - id
      properties:
        type:
          type: string
          enum:
            - user
            - api_key
            - system
          description: >-
            `user` for a change a person made through an authenticated session,
            `api_key` for a public API write, `system` for a cron or webhook
            ingest with no human behind it.
        id:
          type: string
          description: >-
            Identifier of the actor within its `type`. A user id for `user`, a
            key id (never the secret) for `api_key`, a stable job name for
            `system`.
        label:
          type:
            - string
            - "null"
          description: Human-readable name for display. Best effort, may be null.
    ArticlePublishedData:
      type: object
      required:
        - article_id
        - title
        - published_at
        - source
      properties:
        article_id:
          type: string
          format: uuid
        title:
          type: string
        slug:
          type:
            - string
            - "null"
        url:
          type:
            - string
            - "null"
          format: uri
          description: >-
            Canonical public URL. Follows the newsletter's custom website domain
            when it has an active one, otherwise the Commune-hosted path.
        published_at:
          type: string
          format: date-time
          description: >-
            The article's own `posted_at`. Normally equal to `occurred_at`, but
            an import can backdate it to the original publication time.
        source:
          type: string
          enum:
            - native_send
            - import
          description: >-
            `native_send` for an issue Commune emailed itself, `import` for a
            post pulled in from a connected ESP or RSS feed.
        audience_scoped:
          type: boolean
          description: >-
            True when the article is restricted to specific subscriber tags
            rather than the whole list. Which tags is deliberately not here.
            Read the article back from the REST API for that.
    ArticlePublishedEvent:
      title: Article published
      description: An article became publicly readable.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: article.published
            data:
              $ref: "#/components/schemas/ArticlePublishedData"
    ArticleScheduledData:
      type: object
      required:
        - article_id
        - title
        - scheduled_for
      properties:
        article_id:
          type: string
          format: uuid
        title:
          type: string
        scheduled_for:
          type: string
          format: date-time
          description: >-
            When the send is due. Always in the future at the time of this
            event, and lands on a five-minute boundary because that is the
            granularity the dispatch cron picks up.
    ArticleScheduledEvent:
      title: Article scheduled
      description: A native issue was queued for a future send.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: article.scheduled
            data:
              $ref: "#/components/schemas/ArticleScheduledData"
    BillingSubscriptionUpdatedData:
      type: object
      required:
        - subscription_id
        - plan
        - status
      properties:
        subscription_id:
          type: string
          format: uuid
          description: >-
            Commune's own `newsletter_subscriptions` row, one per native
            newsletter. Not a Stripe id. Stripe identifiers are not exposed.
        plan:
          type: string
          enum:
            - creator
            - enterprise
        status:
          type: string
          enum:
            - trialing
            - active
            - past_due
            - canceled
            - trial_expired
        previous_status:
          type:
            - string
            - "null"
          enum:
            - trialing
            - active
            - past_due
            - canceled
            - trial_expired
            - null
          description: >-
            The state being left. Null when it could not be established, so
            treat this as a hint and the new `status` as the truth.
        trial_ends_at:
          type:
            - string
            - "null"
          format: date-time
        current_period_start:
          type:
            - string
            - "null"
          format: date-time
        current_period_end:
          type:
            - string
            - "null"
          format: date-time
        cancel_at_period_end:
          type: boolean
          description: >-
            The subscription is still active but will not renew. Distinct from
            `status: canceled`, which means it already stopped.
    BillingSubscriptionUpdatedEvent:
      title: Billing subscription state changed
      description: The newsletter's own Commune subscription changed state.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: billing.subscription.updated
            data:
              $ref: "#/components/schemas/BillingSubscriptionUpdatedData"
    DeliveryBouncedEvent:
      title: Delivery bounced
      description: The message could not be delivered.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: delivery.bounced
            data:
              allOf:
                - $ref: "#/components/schemas/DeliveryEventData"
                - type: object
                  required:
                    - reason
                  properties:
                    reason:
                      type:
                        - string
                        - "null"
                      maxLength: 1000
                      description: >-
                        The provider's bounce message or type, whichever it
                        supplied. Free text for display. Hard and soft bounces
                        are not distinguished today because Commune stores only
                        this string.
    DeliveryClickedEvent:
      title: Delivery link clicked
      description: The recipient clicked a tracked link.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: delivery.clicked
            data:
              $ref: "#/components/schemas/DeliveryEventData"
    DeliveryComplainedEvent:
      title: Delivery marked as spam
      description: The recipient reported the message as spam.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: delivery.complained
            data:
              allOf:
                - $ref: "#/components/schemas/DeliveryEventData"
                - type: object
                  required:
                    - reason
                  properties:
                    reason:
                      type:
                        - string
                        - "null"
                      maxLength: 1000
                      description: >-
                        Why the row settled as a complaint. Providers rarely
                        elaborate, so expect a constant string.
    DeliveryDeliveredEvent:
      title: Delivery accepted
      description: The provider confirmed the message reached the recipient server.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: delivery.delivered
            data:
              $ref: "#/components/schemas/DeliveryEventData"
    DeliveryEventData:
      type: object
      title: Delivery event body
      description: >-
        Shared body of the five `delivery.*` topics. One `article_deliveries`
        row, identified against both Commune's ids and the provider's message id
        so a consumer can reconcile with its own provider logs.
      required:
        - delivery_id
        - article_id
        - send_id
        - subscriber_id
        - email
        - status
      properties:
        delivery_id:
          type: string
          format: uuid
          description: The `article_deliveries` row.
        article_id:
          type: string
          format: uuid
        send_id:
          type: string
          format: uuid
          description: The send run this delivery belonged to.
        subscriber_id:
          type: string
          format: uuid
        email:
          type: string
          format: email
          description: >-
            The address as it was at send time. Snapshotted onto the delivery
            row, so it does not follow a later change to the subscriber.
        provider_message_id:
          type:
            - string
            - "null"
          description: >-
            The email provider's id for this message, the correlator the inbound
            webhook matched on. Null only if the row was written without one.
        status:
          type: string
          enum:
            - queued
            - sent
            - delivered
            - opened
            - clicked
            - bounced
            - complained
            - failed
          description: >-
            The delivery row's status after this event was applied. Because the
            state machine is forward-only, it is not always the event's own
            name: an open recorded after a click leaves the row on `clicked`.
    DeliveryOpenedEvent:
      title: Delivery opened
      description: The recipient opened the message.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: delivery.opened
            data:
              $ref: "#/components/schemas/DeliveryEventData"
    DomainVerifiedData:
      type: object
      required:
        - domain_id
        - domain
        - verified_at
      properties:
        domain_id:
          type: string
          format: uuid
        domain:
          type: string
          description: >-
            The hostname now serving the newsletter's site. Always a subdomain:
            apex domains cannot CNAME and are rejected before they get this far.
        ssl_status:
          type:
            - string
            - "null"
          description: >-
            The certificate authority's sub-status, kept for display. The
            aggregate `active` state is what Commune actually branches on.
        verified_at:
          type: string
          format: date-time
    DomainVerifiedEvent:
      title: Custom website domain verified
      description: A creator's custom website domain went live.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: domain.verified
            data:
              $ref: "#/components/schemas/DomainVerifiedData"
    EventEnvelope:
      type: object
      title: Event envelope
      description: >-
        The shape every message on every topic shares. The concrete event
        schemas below narrow `type` and fill in `data`; nothing else varies.


        Reserved nulls: `actor` and `idempotency_key` are declared but never
        populated. They are typed nullable rather than omitted so that a
        consumer written today keeps validating unchanged once they start being
        filled in. Do not treat their absence of value as a signal.


        Deduping: `id` is unique per event and stable across redeliveries of
        that event, which makes it the correct dedupe key. Once
        `idempotency_key` is populated, it will additionally let a consumer
        collapse the events caused by one retried write, which `id` cannot do.
      required:
        - id
        - type
        - api_version
        - occurred_at
        - newsletter_id
        - actor
        - idempotency_key
        - data
      properties:
        id:
          type: string
          format: uuid
          description: >-
            Unique id for this event. Stable across redeliveries, so it is the
            dedupe key. It is the primary key of the row the publisher wrote,
            which means it is assigned when the state change is recorded, not
            when the message is dispatched.
          examples:
            - 018f2a8b-6c4b-7d2e-9f11-6a1c3d5e7b90
        type:
          type: string
          description: The topic name, identical to the channel address. Route on this.
          examples:
            - article.published
        api_version:
          type: string
          description: >-
            The `Commune-Version` value this payload conforms to. Present on the
            message itself, not only on the request header, so an event
            persisted to a consumer's own store stays self-describing.
          examples:
            - "2026-08-26"
        occurred_at:
          type: string
          format: date-time
          description: >-
            RFC 3339 timestamp of the state change, not of the delivery attempt.
            Use it to order events, since delivery order is not guaranteed.
        newsletter_id:
          type:
            - string
            - "null"
          format: uuid
          description: >-
            The newsletter the change belongs to. This is the tenant boundary: a
            consumer only ever receives events for newsletters its credential
            can read. Null only for events that are genuinely not
            newsletter-scoped, which none of the 21 topics currently are.
        actor:
          $ref: "#/components/schemas/Actor"
        idempotency_key:
          type:
            - string
            - "null"
          maxLength: 255
          description: >-
            ALWAYS `null` today. Once the public API accepts writes this carries
            the `Idempotency-Key` header of the write that caused the change, so
            a consumer can tie an event back to its own request and collapse the
            duplicates a retried write would otherwise produce. Null stays a
            legitimate value even then: a change made in the product UI or by a
            cron has no originating request to key on.
        data:
          type: object
          description: Event-specific body. Narrowed by each concrete event schema below.
    HighlightCreatedData:
      type: object
      title: Highlight created body
      description: >-
        Body of `highlight.created`. The passage, where it sits in the article,
        and an opaque owner token. Deliberately no `user_id`: Commune does not
        attribute a highlight to a named reader and neither does this event.
      required:
        - highlight_id
        - article_id
        - quote
        - start_offset
        - end_offset
        - owner_key
        - message_id
        - created_at
      properties:
        highlight_id:
          type: string
          format: uuid
          description: The `article_highlights` row.
        article_id:
          type: string
          format: uuid
        quote:
          type: string
          maxLength: 5000
          description: >-
            The marked text itself, as plain text, capped at 5000 characters on
            write. The surrounding prefix and suffix that let a client re-anchor
            the range are not here; read the highlight back from the REST API
            for those.
        start_offset:
          type: integer
          minimum: 0
          description: >-
            Where the passage starts, as a character offset into the article's
            normalised plain text. Always less than `end_offset`.
        end_offset:
          type: integer
          minimum: 1
          description: Where the passage ends, in the same offsets.
        owner_key:
          type: string
          description: >-
            An opaque, stable per highlighter value, scoped to this one article.
            The same value the REST resource returns. Group by it to tell one
            reader's marks apart from another's. It cannot be resolved to a
            person and does not correlate across articles.
          examples:
            - 4f2a9c1e7b3d6a05
        message_id:
          type:
            - string
            - "null"
          format: uuid
          description: >-
            The chat message the reader wrote from this passage, when the
            highlight was created as a discussion. Null for a silent highlight,
            and it stays null on this topic: attaching a message later updates
            the row rather than creating one.
        created_at:
          type: string
          format: date-time
    HighlightCreatedEvent:
      title: Highlight created
      description: A reader marked a passage of an article.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: highlight.created
            data:
              $ref: "#/components/schemas/HighlightCreatedData"
    ImportCompletedData:
      type: object
      title: Import completed body
      description: >-
        Body of `import.completed`. Which kind of run finished, where its rows
        came from, and how much it moved.
      required:
        - kind
        - source
        - imported_count
        - completed_at
      properties:
        kind:
          type: string
          enum:
            - articles
            - subscribers
            - migration
          description: >-
            `articles` and `subscribers` ingest rows. `migration` is the
            finalize step that moves a newsletter onto native sending, which
            ingests nothing.
        source:
          type:
            - string
            - "null"
          description: >-
            Where the rows came from: an ESP slug such as `kit` or `beehiiv`,
            `csv` for an uploaded list, or `rss` for a feed. Null when the run
            has no single source, which is the `migration` case.
          examples:
            - kit
        imported_count:
          type:
            - integer
            - "null"
          minimum: 0
          description: >-
            Rows that actually landed. Not the size of what was offered: both
            import paths skip duplicates, and the subscriber upsert preserves
            the status of a row that already existed rather than overwriting it.
            Null for `migration`, which ingests nothing.


            Rows the run could not take are not reported. Per-row failures are
            swallowed so one bad row cannot end the run, and none of the ingest
            paths counts them apart from the duplicates it skipped on purpose,
            so a count here would be a guess.
        started_at:
          type:
            - string
            - "null"
          format: date-time
          description: >-
            When the run began. Null when it was not recorded, so treat the
            duration as best effort.
        completed_at:
          type: string
          format: date-time
          description: When the run ended. Equal to `occurred_at`.
    ImportCompletedEvent:
      title: Import finished
      description: An article, subscriber or migration run finished.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: import.completed
            data:
              $ref: "#/components/schemas/ImportCompletedData"
    MessageCreatedData:
      type: object
      required:
        - message_id
        - thread_id
        - thread_level
        - content
        - created_at
      properties:
        message_id:
          type: string
          format: uuid
        short_id:
          type:
            - string
            - "null"
        url:
          type:
            - string
            - "null"
          format: uri
        thread_id:
          type: string
          format: uuid
          description: The top-level thread this reply belongs to.
        parent_id:
          type:
            - string
            - "null"
          format: uuid
          description: >-
            The message being replied to. Equal to `thread_id` for a direct
            reply, a sibling message for a nested one.
        thread_level:
          type: integer
          enum:
            - 1
            - 2
          description: >-
            Nesting depth. 1 is a reply to the thread, 2 is a reply to a reply,
            and nesting stops there.
        author:
          $ref: "#/components/schemas/UserRef"
        content:
          type: string
          maxLength: 5000
        created_at:
          type: string
          format: date-time
    MessageCreatedEvent:
      title: Message created
      description: A reply was posted inside a thread.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: message.created
            data:
              $ref: "#/components/schemas/MessageCreatedData"
    SendCompletedData:
      type: object
      required:
        - article_id
        - send_id
        - started_at
        - completed_at
        - recipient_count
        - sent_count
        - failed_count
      properties:
        article_id:
          type: string
          format: uuid
        send_id:
          type: string
          format: uuid
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
        recipient_count:
          type: integer
          minimum: 0
          description: >-
            Audience size fixed at the moment the run started. The stable
            denominator for any rate a consumer computes.
        sent_count:
          type: integer
          minimum: 0
          description: >-
            Recipients the provider accepted at dispatch. NOT confirmed
            deliveries: confirmation arrives later as `delivery.delivered`, one
            event per recipient.
        failed_count:
          type: integer
          minimum: 0
          description: Recipients the provider rejected outright at dispatch.
    SendCompletedEvent:
      title: Send completed
      description: A send run finished handing every recipient to the provider.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: send.completed
            data:
              $ref: "#/components/schemas/SendCompletedData"
    SendFailedData:
      type: object
      required:
        - article_id
        - failure_reason
      properties:
        article_id:
          type: string
          format: uuid
        send_id:
          type:
            - string
            - "null"
          format: uuid
          description: >-
            Null when the run broke before it was recorded, which is early
            enough that there is no run to point at.
        failure_reason:
          type: string
          maxLength: 1000
          description: >-
            Human-readable, truncated at 1000 characters on write. Meant for
            display, not for branching. Match on the topic, not this string.
    SendFailedEvent:
      title: Send failed
      description: A send run broke and the article was parked in failed.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: send.failed
            data:
              $ref: "#/components/schemas/SendFailedData"
    SenderVerifiedData:
      type: object
      required:
        - sender_id
        - from_email
        - domain
        - kind
        - verified_at
      properties:
        sender_id:
          type: string
          format: uuid
        from_email:
          type: string
          format: email
          description: Ready-to-use From address.
        from_name:
          type: string
        reply_to_email:
          type:
            - string
            - "null"
          format: email
        domain:
          type: string
          description: The sending domain that was verified.
        kind:
          type: string
          enum:
            - native
            - custom
          description: >-
            `native` is a Commune-provisioned subdomain, `custom` is a domain
            the creator owns and pointed at Commune.
        is_default:
          type: boolean
          description: >-
            Whether this is the newsletter's default sender, the one used when
            an issue does not pin a specific one.
        verified_at:
          type: string
          format: date-time
    SenderVerifiedEvent:
      title: Sender verified
      description: A sending identity passed verification and can now send.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: sender.verified
            data:
              $ref: "#/components/schemas/SenderVerifiedData"
    SubscriberCreatedData:
      type: object
      required:
        - subscriber_id
        - email
        - status
        - resubscribed
        - created_at
      properties:
        subscriber_id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        status:
          type: string
          enum:
            - subscribed
            - unsubscribed
            - bounced
            - complained
            - pending
        user_id:
          type:
            - string
            - "null"
          description: >-
            The Commune account behind this subscriber, when there is one. Null
            for an address imported from an ESP or CSV that never signed up.
        acquisition_source:
          type:
            - string
            - "null"
          description: >-
            How a subscriber was imported, frozen at insert so a later migration
            between providers cannot relabel it. An ESP import stamps that
            provider's slug, and a CSV upload stamps `csv`. Set only on those
            two paths, and therefore null for a subscriber who subscribed in the
            Commune app, finished signup, or accepted an invitation. Two further
            values, `commune` and `imported`, appear only on rows that predate
            this field and were labelled once when it was introduced; nothing
            writes either of them today.
        resubscribed:
          type: boolean
          description: >-
            True when an existing unsubscribed row was flipped back to
            `subscribed` rather than a new row inserted.
        created_at:
          type: string
          format: date-time
          description: >-
            When the subscriber row was first created. On a resubscribe this is
            the ORIGINAL creation time, not the reactivation, which is
            `occurred_at`.
    SubscriberCreatedEvent:
      title: Subscriber created
      description: Someone became a subscriber, or an unsubscribed one came back.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: subscriber.created
            data:
              $ref: "#/components/schemas/SubscriberCreatedData"
    SubscriberStatusChangedData:
      type: object
      description: >-
        Body of `subscriber.status_changed`. Identifiers plus what describes the
        crossing, and deliberately not the insight resource: read `GET
        /newsletters/{newsletter}/insights` for the full scored row.
      required:
        - subscriber_id
        - user_id
        - email
        - previous_status
        - status
        - direction
        - total_score
        - velocity
      properties:
        subscriber_id:
          type: string
          format: uuid
          description: >-
            The subscriber this score belongs to, the same identifier the other
            `subscriber.*` topics carry.
        user_id:
          type: string
          description: >-
            The Commune account the score is attributed to. Insights are keyed
            on the account rather than on the subscription, because half the
            signal is community activity that belongs to a person and not to a
            mailing list entry. Always present: an unscored subscriber cannot
            cross a boundary.
        email:
          type: string
          format: email
          description: >-
            Where the newsletter reaches this reader, so a consumer can match
            the event against its own records without a second call. Resolved
            from the Commune account when the subscription row itself carries no
            address.
        previous_status:
          oneOf:
            - $ref: "#/components/schemas/InsightStatus"
            - type: "null"
          description: >-
            The status this reader held before the pass. Null the first time a
            reader is scored, which is a real crossing worth delivering rather
            than a gap in the data.
        status:
          $ref: "#/components/schemas/InsightStatus"
        direction:
          type: string
          enum:
            - promoted
            - demoted
          description: >-
            Which way along the ladder the reader moved, derived from the two
            statuses so a consumer does not have to hardcode their order.
            `promoted` the first time a reader is scored, because there is no
            earlier position they could have fallen from.
        total_score:
          type: integer
          description: >-
            The blended score at the moment of the crossing, summed across the
            community and the newsletter's email provider. It has no unit and no
            ceiling, and is meaningful only ranked against the other readers of
            the same newsletter.
        velocity:
          type: string
          enum:
            - rising
            - cooling
            - steady
          description: >-
            The last fourteen days of points against the fourteen before them.
            Read it with `direction`: a demotion while `rising` means the
            audience around this reader moved faster, not that the reader slowed
            down.
        last_action_at:
          type:
            - string
            - "null"
          format: date-time
          description: >-
            When the reader last did anything that earned points. The reader's
            own clock, not the scoring pass's, so it sits earlier than
            `occurred_at` by up to a full scoring interval. Null for a reader
            who has never acted.
    SubscriberStatusChangedEvent:
      title: Subscriber insight status changed
      description: A reader crossed a boundary in the newsletter's engagement ladder.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: subscriber.status_changed
            data:
              $ref: "#/components/schemas/SubscriberStatusChangedData"
    SubscriberTaggedData:
      type: object
      title: Subscriber tagged body
      description: >-
        Body of `subscriber.tagged`. Which subscriber, which tag, and which way
        the membership moved.
      required:
        - subscriber_id
        - tag_id
        - tag_name
        - direction
      properties:
        subscriber_id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        tag_id:
          type: string
          format: uuid
        tag_name:
          type: string
          description: The tag's name at the time of the change.
        direction:
          type: string
          enum:
            - assigned
            - removed
          description: >-
            Whether the subscriber joined the segment or left it. Branch on
            this: it is the only thing that differs between the two changes this
            topic carries.
    SubscriberTaggedEvent:
      title: Subscriber tag added or removed
      description: A subscriber tag was applied or taken off.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: subscriber.tagged
            data:
              $ref: "#/components/schemas/SubscriberTaggedData"
    SubscriberUnsubscribedData:
      type: object
      required:
        - subscriber_id
        - email
        - reason
        - unsubscribed_at
      properties:
        subscriber_id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        reason:
          type: string
          enum:
            - self_service
            - bounced
            - complained
          description: >-
            `self_service` is the reader opting out. `bounced` and `complained`
            are forced by delivery telemetry and arrive with the matching
            `delivery.*` event.
        unsubscribed_at:
          type: string
          format: date-time
        article_send_id:
          type:
            - string
            - "null"
          format: uuid
          description: >-
            The send whose email the reader acted on, when the unsubscribe link
            carried it. Best-effort attribution: null on an in-app unsubscribe,
            a link without the marker, or a bounce or complaint.
    SubscriberUnsubscribedEvent:
      title: Subscriber unsubscribed
      description: A subscriber stopped being mailable.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: subscriber.unsubscribed
            data:
              $ref: "#/components/schemas/SubscriberUnsubscribedData"
    ThreadCreatedData:
      type: object
      required:
        - thread_id
        - visibility
        - content
        - created_at
      properties:
        thread_id:
          type: string
          format: uuid
          description: >-
            The thread itself, which is the opening message of the conversation
            rather than a reply within one.
        short_id:
          type:
            - string
            - "null"
          description: Short public identifier used in thread URLs.
        url:
          type:
            - string
            - "null"
          format: uri
        author:
          $ref: "#/components/schemas/UserRef"
        content:
          type: string
          maxLength: 5000
          description: The opening message, capped at 5000 characters on write.
        visibility:
          type: string
          enum:
            - public
            - subscribers
            - paid
          description: >-
            `public` puts the thread on the global feed and only team members
            may set it. `subscribers` keeps it in the newsletter's own space.
            `paid` is reserved and unused.
        article_id:
          type:
            - string
            - "null"
          format: uuid
          description: >-
            Set when the thread is an article's discussion. This is where
            article comments live now; the old `article_comments` table is dead.
        created_at:
          type: string
          format: date-time
    ThreadCreatedEvent:
      title: Thread created
      description: A new top-level thread was started in a newsletter's space.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: thread.created
            data:
              $ref: "#/components/schemas/ThreadCreatedData"
    ThreadPublishedData:
      type: object
      title: Thread published body
      description: >-
        Body of `thread.published`. Which thread reached the feed, how it got
        there, and enough to render a feed row without a second call.
      required:
        - thread_id
        - visibility
        - previous_visibility
        - source
        - published_at
      properties:
        thread_id:
          type: string
          format: uuid
          description: >-
            The thread. The same id `thread.created` carried, so a consumer that
            stored that event can match on it directly.
        short_id:
          type:
            - string
            - "null"
          description: Short public identifier used in thread URLs.
        url:
          type:
            - string
            - "null"
          format: uri
        author:
          $ref: "#/components/schemas/UserRef"
        visibility:
          type: string
          const: public
          description: >-
            Always `public`. Present so a payload stays self-describing next to
            `thread.created`, which carries the same field and can carry other
            values.
        previous_visibility:
          type:
            - string
            - "null"
          enum:
            - subscribers
            - paid
            - null
          description: >-
            What the thread was before. Null when it was created public, which
            is the case `source` also reports.
        source:
          type: string
          enum:
            - created_public
            - visibility_changed
          description: >-
            `created_public` for a thread a team member started public, which
            fires `thread.created` from the same write. `visibility_changed` for
            one that was featured later.
        published_by:
          allOf:
            - $ref: "#/components/schemas/UserRef"
          description: >-
            The team member who put it on the feed. Named `published_by` rather
            than `actor` because the envelope already has an `actor`, which
            means something different and is always null today. Null on the
            `created_public` path, where a thread was public from the start and
            nobody featured it.
        article_id:
          type:
            - string
            - "null"
          format: uuid
          description: >-
            Set when the thread is an article's discussion rather than a
            standalone one.
        published_at:
          type: string
          format: date-time
          description: >-
            `visibility_changed_at` on the `visibility_changed` path, the
            thread's `created_at` on the `created_public` path.
    ThreadPublishedEvent:
      title: Thread published to the feed
      description: A thread's visibility became public and it hit the global feed.
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: thread.published
            data:
              $ref: "#/components/schemas/ThreadPublishedData"
    UserRef:
      type:
        - object
        - "null"
      title: User reference
      description: >-
        A pointer to a Commune account, enough to attribute and render something
        without a second call. Null when there is no account to point at, either
        because there never was one or because the account was deleted. Content
        outlives its author, so a null here is expected rather than a sign the
        reference was lost.
      required:
        - user_id
      properties:
        user_id:
          type: string
        username:
          type:
            - string
            - "null"
        display_name:
          type:
            - string
            - "null"
        avatar_url:
          type:
            - string
            - "null"
          format: uri
externalDocs:
  description: >-
    Guides, getting started, authentication and the webhook verification
    walkthrough. This reference describes every operation; the guides describe
    how to string them together.
  url: https://usecommune.com/docs
x-tagGroups:
  - name: Newsletters and people
    tags:
      - Newsletters
      - Team
      - Users
      - Search
  - name: Publishing
    tags:
      - Articles
      - Highlights
  - name: Community
    tags:
      - Threads
      - Messages
  - name: Audience
    tags:
      - Subscribers
      - Subscriber tags
  - name: Insights
    tags:
      - Engagement
      - Metrics
  - name: Sending and domains
    tags:
      - Senders
      - Website domains
  - name: Platform
    tags:
      - Platform
      - Webhooks
