openapi: 3.1.1
info:
  title: BandTools API
  version: "v1"
  summary: Email newsletters for musicians.
  description: |
    The BandTools API is designed to be predictable, explicit and safe to
    use in production. It is organised around REST, with resource-oriented URLs,
    JSON-encoded request and response bodies, and standard HTTP verbs,
    authentication and status codes. File uploads use multipart form data.

    The current version is `v1`, served under the `/api/v1` path prefix.
    The human-readable reference lives at
    [https://bandtools.app/api/v1](https://bandtools.app/api/v1).
  contact:
    name: BandTools
    url: https://bandtools.app/contact
    email: support@bandtools.app
  license:
    name: Proprietary
servers:
  - url: https://bandtools.app/api/v1
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Subscribers
    description: Manage subscribers to your newsletter.
  - name: Imports
    description: Bulk-add subscribers via CSV upload or JSON list.
  - name: Account
    description: Your account profile.
  - name: Account picture
    description: The profile picture displayed on your account.
  - name: App settings
    description: Preferences that control the BandTools dashboard.
  - name: Newsletter settings
    description: The name and description of your newsletter, plus notification preferences.
  - name: Themes
    description: Reusable colour and typography themes for your subscriber-facing pages.
  - name: Page designs
    description: The archive, subscribe, confirmation, and unsubscribe pages seen by subscribers.
  - name: Confirmation email
    description: The double-opt-in email sent when someone subscribes.
  - name: Newsletters
    description: Draft, schedule, and send newsletter issues.
  - name: Attachments
    description: Upload files that can be referenced inline in newsletter HTML.
  - name: Collaborators
    description: Manage collaborators on a newsletter draft.
  - name: Shared newsletters
    description: List newsletter drafts that others have shared with you.
  - name: Locks
    description: Pessimistic editing leases for coordinating concurrent newsletter editing.
  - name: Automatic newsletters
    description: Poll RSS or Atom feeds and turn each new entry into a newsletter automatically.
  - name: Webhooks
    description: Subscribe to real-time event notifications delivered to your own HTTPS endpoint.
paths:
  /subscribers:
    get:
      tags: [Subscribers]
      summary: List subscribers
      description: Return a paginated list of the authenticated account's subscribers.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
        - name: sort
          in: query
          description: Sort order.
          schema:
            type: string
            enum: [email_asc, email_desc, subscribed_recent, subscribed_oldest]
            default: email_asc
        - name: filter
          in: query
          description: Restrict the result set by confirmation status.
          schema:
            type: string
            enum: [all, confirmed, unconfirmed]
            default: all
      responses:
        "200":
          description: A page of subscribers.
          headers:
            Link:
              $ref: "#/components/headers/Link"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriberListResponse"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/PaginationOutOfRange" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Subscribers]
      summary: Add a subscriber
      description: |
        Add a subscriber by email address. If the address is already subscribed,
        the existing subscription is returned unchanged and the status is `200`;
        otherwise a new subscription is created and the status is `201`. When
        the newsletter is configured for double opt-in, a confirmation email is
        sent.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubscriberCreateRequest"
            examples:
              flat:
                summary: Top-level email_address
                value: { email_address: "fan@example.com" }
              nested:
                summary: Nested under subscriber
                value: { subscriber: { email_address: "fan@example.com" } }
      responses:
        "200":
          description: The subscription already existed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubscriberResponse" }
        "201":
          description: A new subscription was created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubscriberResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Subscribers]
      summary: Delete all subscribers
      description: |
        Irreversibly remove every subscriber on the authenticated account. The
        query parameter `confirm=true` is required; otherwise a validation error
        is returned.
      parameters:
        - name: confirm
          in: query
          required: true
          description: Must be literally `true` to confirm the destructive action.
          schema:
            type: string
            enum: [true]
      responses:
        "200":
          description: The number of subscribers removed.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: object
                    required: [removed]
                    properties:
                      removed:
                        type: integer
                        minimum: 0
                        example: 412
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /subscribers/{id}:
    parameters:
      - $ref: "#/components/parameters/SubscriberId"
    get:
      tags: [Subscribers]
      summary: Get a subscriber
      responses:
        "200":
          description: The requested subscriber.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubscriberResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Subscribers]
      summary: Delete a subscriber
      responses:
        "204":
          description: The subscriber has been removed.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /subscribers/imports:
    post:
      tags: [Imports]
      summary: Import subscribers
      description: |
        Queue an asynchronous import. Accepts either a CSV file (as
        `multipart/form-data` with a `file` part) or a JSON list of email
        addresses. The response includes a `Location` header pointing at the
        status endpoint that can be polled until the import completes.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: A CSV file with one email address per row. Maximum 20 MiB.
          application/json:
            schema:
              type: object
              required: [email_addresses]
              properties:
                email_addresses:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items:
                    type: string
                    format: email
            examples:
              inline:
                value:
                  email_addresses:
                    - one@example.com
                    - two@example.com
      responses:
        "202":
          description: Import queued.
          headers:
            Location:
              description: URL to poll for import status.
              schema: { type: string, format: uri }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubscriberImportResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /subscribers/imports/{id}:
    parameters:
      - $ref: "#/components/parameters/ImportId"
    get:
      tags: [Imports]
      summary: Get import status
      responses:
        "200":
          description: The current state of the import.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubscriberImportResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account:
    get:
      tags: [Account]
      summary: Get the current account
      responses:
        "200":
          description: Account details for the authenticated user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Account]
      summary: Update the current account
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account]
              properties:
                account: { $ref: "#/components/schemas/AccountWrite" }
      responses:
        "200":
          description: The updated account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/picture:
    get:
      tags: [Account picture]
      summary: Download the account picture
      description: |
        Redirects (`302`) to a short-lived signed URL hosting the picture.
        Returns `404` with an error body when no picture is attached.
      responses:
        "302":
          description: Redirect to the stored image.
          headers:
            Location:
              description: Signed URL hosting the image.
              schema: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    put:
      tags: [Account picture]
      summary: Upload or replace the account picture
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [picture]
              properties:
                picture:
                  type: string
                  format: binary
                  description: GIF, JPEG, PNG, or WebP image up to 5 MiB.
      responses:
        "200":
          description: Picture metadata after a successful upload.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountPictureResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Account picture]
      summary: Remove the account picture
      responses:
        "204":
          description: The picture has been removed.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/settings:
    get:
      tags: [App settings]
      summary: Get app settings
      responses:
        "200":
          description: The authenticated account's app settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountSettingsResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [App settings]
      summary: Update app settings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [settings]
              properties:
                settings: { $ref: "#/components/schemas/AccountSettings" }
      responses:
        "200":
          description: The updated settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountSettingsResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/newsletter-settings:
    get:
      tags: [Newsletter settings]
      summary: Get newsletter settings
      responses:
        "200":
          description: The current newsletter settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountNewsletterSettingsResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Newsletter settings]
      summary: Update newsletter settings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [newsletter_settings]
              properties:
                newsletter_settings: { $ref: "#/components/schemas/AccountNewsletterSettings" }
      responses:
        "200":
          description: The updated newsletter settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountNewsletterSettingsResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/themes:
    get:
      tags: [Themes]
      summary: List themes
      description: |
        Returns a paginated list of user-owned and system themes. Requires the
        `page_themes` plan feature.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
        - name: filter
          in: query
          schema:
            type: string
            enum: [all, user, system]
            default: all
        - name: sort
          in: query
          schema:
            type: string
            enum: [name_asc, name_desc, created_asc, created_desc]
            default: name_asc
      responses:
        "200":
          description: A page of themes.
          headers:
            Link: { $ref: "#/components/headers/Link" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageThemeListResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/PaginationOutOfRange" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Themes]
      summary: Create a theme
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [theme]
              properties:
                theme: { $ref: "#/components/schemas/PageThemeWrite" }
      responses:
        "201":
          description: The newly created theme.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageThemeResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/themes/{id}:
    parameters:
      - $ref: "#/components/parameters/ThemeId"
    get:
      tags: [Themes]
      summary: Get a theme
      responses:
        "200":
          description: The requested theme.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageThemeResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Themes]
      summary: Update a theme
      description: Only user-owned themes can be updated.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [theme]
              properties:
                theme: { $ref: "#/components/schemas/PageThemeWrite" }
      responses:
        "200":
          description: The updated theme.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageThemeResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Themes]
      summary: Delete a theme
      description: |
        A `422 validation_failed` with `details[0].code = "theme_in_use"` is
        returned if the theme is currently applied to a page design.
      responses:
        "204":
          description: The theme has been removed.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/archive-page:
    get:
      tags: [Page designs]
      summary: Get the archive page design
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Page designs]
      summary: Update the archive page design
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [archive_page]
              properties:
                archive_page: { $ref: "#/components/schemas/PageDesignWrite" }
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/archive-page/background-image:
    put:
      tags: [Page designs]
      summary: Upload the archive page background image
      requestBody: { $ref: "#/components/requestBodies/BackgroundImageUpload" }
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Page designs]
      summary: Remove the archive page background image
      responses:
        "204":
          description: The background image has been removed.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/subscribe-page:
    get:
      tags: [Page designs]
      summary: Get the subscribe page design
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Page designs]
      summary: Update the subscribe page design
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subscribe_page]
              properties:
                subscribe_page: { $ref: "#/components/schemas/PageDesignWrite" }
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/subscribe-page/background-image:
    put:
      tags: [Page designs]
      summary: Upload the subscribe page background image
      requestBody: { $ref: "#/components/requestBodies/BackgroundImageUpload" }
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Page designs]
      summary: Remove the subscribe page background image
      responses:
        "204":
          description: The background image has been removed.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/confirmation-page:
    get:
      tags: [Page designs]
      summary: Get the confirmation page design
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Page designs]
      summary: Update the confirmation page design
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirmation_page]
              properties:
                confirmation_page: { $ref: "#/components/schemas/PageDesignWrite" }
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/confirmation-page/background-image:
    put:
      tags: [Page designs]
      summary: Upload the confirmation page background image
      requestBody: { $ref: "#/components/requestBodies/BackgroundImageUpload" }
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Page designs]
      summary: Remove the confirmation page background image
      responses:
        "204":
          description: The background image has been removed.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/unsubscribe-page:
    get:
      tags: [Page designs]
      summary: Get the unsubscribe page design
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Page designs]
      summary: Update the unsubscribe page design
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [unsubscribe_page]
              properties:
                unsubscribe_page: { $ref: "#/components/schemas/PageDesignWrite" }
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/unsubscribe-page/background-image:
    put:
      tags: [Page designs]
      summary: Upload the unsubscribe page background image
      requestBody: { $ref: "#/components/requestBodies/BackgroundImageUpload" }
      responses:
        "200": { $ref: "#/components/responses/PageDesignOk" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Page designs]
      summary: Remove the unsubscribe page background image
      responses:
        "204":
          description: The background image has been removed.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/confirmation-email:
    get:
      tags: [Confirmation email]
      summary: Get the confirmation email
      responses:
        "200":
          description: The current confirmation email.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConfirmationEmailResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Confirmation email]
      summary: Update the confirmation email
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirmation_email]
              properties:
                confirmation_email: { $ref: "#/components/schemas/ConfirmationEmailWrite" }
      responses:
        "200":
          description: The updated confirmation email.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConfirmationEmailResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters:
    get:
      tags: [Newsletters]
      summary: List newsletters
      description: |
        Newsletters are grouped by status. The `status` query parameter is
        required and determines the default sort order.
      parameters:
        - name: status
          in: query
          required: true
          schema:
            type: string
            enum: [draft, sent, scheduled]
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
        - name: sort
          in: query
          schema:
            type: string
            enum:
              - subject_asc
              - subject_desc
              - created_asc
              - created_desc
              - sent_asc
              - sent_desc
              - scheduled_asc
              - scheduled_desc
      responses:
        "200":
          description: A page of newsletters (without message/html bodies).
          headers:
            Link: { $ref: "#/components/headers/Link" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterListResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/PaginationOutOfRange" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Newsletters]
      summary: Create a draft newsletter
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewsletterWrite" }
      responses:
        "201":
          description: The newly created draft newsletter.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{id}:
    parameters:
      - $ref: "#/components/parameters/NewsletterId"
    get:
      tags: [Newsletters]
      summary: Get a newsletter
      responses:
        "200":
          description: The requested newsletter, including `message` and rendered `html`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Newsletters]
      summary: Update a draft newsletter
      description: |
        Only draft newsletters can be updated. Updating a sent newsletter
        returns `409 conflict`. Supplying `scheduled_for` has no effect here —
        use the schedule endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewsletterWrite" }
      responses:
        "200":
          description: The updated newsletter.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Newsletters]
      summary: Delete a draft newsletter
      description: Sent newsletters cannot be deleted and return `409 conflict`.
      responses:
        "204":
          description: The draft newsletter has been deleted.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{id}/archive:
    parameters:
      - $ref: "#/components/parameters/NewsletterId"
    post:
      tags: [Newsletters]
      summary: Add to archive
      description: |
        Adds a sent newsletter to the public archive. Returns `409 conflict` if the
        newsletter has not been sent yet. Requires the `newsletter_archive` plan
        feature (Headliner). Idempotent — archiving an already-public newsletter
        returns `200` unchanged.
      responses:
        "200":
          description: Newsletter is now in the public archive.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Newsletters]
      summary: Remove from archive
      description: |
        Removes a sent newsletter from the public archive and clears its pinned
        status. Returns `409 conflict` if the newsletter has not been sent yet.
        Requires the `newsletter_archive` plan feature (Headliner). Idempotent —
        removing an already-private newsletter returns `200` unchanged.
      responses:
        "200":
          description: Newsletter has been removed from the public archive.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{id}/duplicate:
    parameters:
      - $ref: "#/components/parameters/NewsletterId"
    post:
      tags: [Newsletters]
      summary: Duplicate a newsletter
      description: |
        Creates a new draft newsletter by duplicating the specified newsletter.
        The copy has its subject appended with "copy" (or "copy N" if duplicates
        already exist), and is reset to draft status. Requires the
        `duplicate_newsletter` plan feature (Artist or Headliner).
      responses:
        "201":
          description: The duplicated newsletter has been created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{id}/preview:
    parameters:
      - $ref: "#/components/parameters/NewsletterId"
    post:
      tags: [Newsletters]
      summary: Send a preview
      description: Queues a preview send to the account's verified email address.
      responses:
        "202":
          description: Preview queued.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: object
                    required: [status]
                    properties:
                      status:
                        type: string
                        enum: [queued]
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{id}/send:
    parameters:
      - $ref: "#/components/parameters/NewsletterId"
    post:
      tags: [Newsletters]
      summary: Send a newsletter now
      description: |
        Queues the newsletter for immediate delivery. Dispatches a
        `newsletter.sent` webhook. Preflight failures return
        `422 unprocessable` with a human-readable message.
      responses:
        "202":
          description: Send queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{id}/send-to-new-subscribers:
    parameters:
      - $ref: "#/components/parameters/NewsletterId"
    post:
      tags: [Newsletters]
      summary: Send to new subscribers
      description: |
        Sends a previously-sent newsletter to subscribers who joined after it was
        originally sent. Dispatches a `newsletter.sent` webhook. Returns
        `409 conflict` if the newsletter has not been sent yet, or
        `422 unprocessable` if no new subscribers are found or the account has a
        failed payment.
      responses:
        "202":
          description: Send to new subscribers queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: queued
                      new_subscribers_count:
                        type: integer
                        example: 12
                  meta:
                    $ref: "#/components/schemas/Meta"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{id}/schedule:
    parameters:
      - $ref: "#/components/parameters/NewsletterId"
    post:
      tags: [Newsletters]
      summary: Schedule a newsletter
      description: |
        Schedule a draft newsletter for future delivery. Requires the
        Artist or Headliner plan (`schedule_newsletter` feature). Dispatches a
        `newsletter.scheduled` webhook. `scheduled_for` must be a future
        ISO 8601 timestamp, on a whole hour, and no more than 32 days ahead.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scheduled_for]
              properties:
                scheduled_for:
                  type: string
                  format: date-time
                  example: "2026-05-01T10:00:00Z"
      responses:
        "202":
          description: The newsletter has been scheduled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Newsletters]
      summary: Cancel a schedule
      description: Idempotent. Returns `200` even when no schedule is currently set.
      responses:
        "200":
          description: The newsletter, with the schedule cleared.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{id}/pin:
    parameters:
      - $ref: "#/components/parameters/NewsletterId"
    post:
      tags: [Newsletters]
      summary: Pin a newsletter
      description: |
        Pins the newsletter to the top of the user's public newsletter archive.
        Requires the `newsletter_archive` plan feature. The newsletter must be
        sent and `public: true`; otherwise the request returns `409 conflict`
        with `error.code = "not_in_archive"`. At most one newsletter can be
        pinned per user — pinning a different newsletter automatically unpins
        the previous one. Idempotent: re-pinning an already-pinned newsletter
        returns `200` without changing state.
      responses:
        "200":
          description: The newsletter, now pinned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Newsletters]
      summary: Unpin a newsletter
      description: |
        Unpins the newsletter from the top of the user's public archive.
        Requires the `newsletter_archive` plan feature. Idempotent: returns
        `200` even when the newsletter is not currently pinned.
      responses:
        "200":
          description: The newsletter, now unpinned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{newsletter_id}/collaborators:
    parameters:
      - name: newsletter_id
        in: path
        required: true
        description: Newsletter slug.
        schema: { type: string }
    get:
      tags: [Collaborators]
      summary: List collaborators
      description: |
        Lists all collaborators (pending and accepted) on a newsletter.
        Owner-only.
      parameters:
        - name: sort
          in: query
          description: Sort order.
          schema:
            type: string
            enum: [name_asc, name_desc, email_asc, email_desc]
            default: name_asc
      responses:
        "200":
          description: The list of collaborators.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollaboratorListResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Collaborators]
      summary: Invite a collaborator
      description: |
        Invites a user by email address to collaborate on editing the draft.
        Requires the `share_newsletter` plan feature. An invitation email is
        sent automatically. Maximum 10 collaborators per newsletter.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email_address]
              properties:
                email_address:
                  type: string
                  format: email
                  example: "collaborator@example.com"
      responses:
        "201":
          description: The collaborator invitation was created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CollaboratorResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{newsletter_id}/collaborators/{id}:
    parameters:
      - name: newsletter_id
        in: path
        required: true
        description: Newsletter slug.
        schema: { type: string }
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The collaborator share ID.
    delete:
      tags: [Collaborators]
      summary: Revoke a collaborator
      description: Revokes a collaborator's access. Works for both pending and accepted invitations.
      responses:
        "204":
          description: The collaborator has been revoked.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{newsletter_id}/lock:
    parameters:
      - name: newsletter_id
        in: path
        required: true
        description: Newsletter slug.
        schema: { type: string }
    post:
      tags: [Locks]
      summary: Acquire editing lock
      description: |
        Acquires a five-minute editing lease on the newsletter. Both the
        owner and accepted collaborators can acquire the lock. Returns `409`
        when another user currently holds the lock. Collaborators receive
        `409` on scheduled newsletters.
      responses:
        "200":
          description: The lock was acquired.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LockResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Locks]
      summary: Release editing lock
      description: Releases the editing lease. No-op if you do not hold the lock.
      responses:
        "204":
          description: The lock has been released.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/{newsletter_id}/lock/heartbeat:
    parameters:
      - name: newsletter_id
        in: path
        required: true
        description: Newsletter slug.
        schema: { type: string }
    patch:
      tags: [Locks]
      summary: Refresh editing lock
      description: |
        Extends the editing lease by another five minutes. Must be called
        by the current lock holder; returns `409` otherwise.
      responses:
        "200":
          description: The lock has been refreshed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LockResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /shared-newsletters:
    get:
      tags: [Shared newsletters]
      summary: List shared newsletters
      description: |
        Lists draft newsletters that other users have shared with the
        authenticated user (accepted invitations only). Each newsletter
        includes an `owner` object.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
        - name: sort
          in: query
          schema:
            type: string
            enum: [updated_recent, updated_oldest, subject_asc, subject_desc]
            default: updated_recent
      responses:
        "200":
          description: A page of shared newsletters.
          headers:
            Link: { $ref: "#/components/headers/Link" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewsletterListResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/PaginationOutOfRange" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /automatic-newsletters:
    get:
      tags: [Automatic newsletters]
      summary: List automatic newsletters
      description: |
        Returns a paginated list of automatic newsletter resources for the
        authenticated account. Requires the `automatic_newsletters` plan
        feature.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
        - name: sort
          in: query
          description: Sort order.
          schema:
            type: string
            enum: [name_asc, name_desc, created_asc, created_desc]
            default: created_desc
      responses:
        "200":
          description: A page of automatic newsletters.
          headers:
            Link: { $ref: "#/components/headers/Link" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomaticNewsletterListResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/PaginationOutOfRange" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Automatic newsletters]
      summary: Create an automatic newsletter
      description: |
        Creates a new automatic newsletter and fetches the feed once to
        populate `feed_title`, `site_url`, and the cached entry metadata.
        Requires the `automatic_newsletters` plan feature and `write`
        scope. Hitting the per-account limit returns
        `error.details[].code = "limit_reached"`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AutomaticNewsletterWriteRequest" }
      responses:
        "201":
          description: The newly created automatic newsletter.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomaticNewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /automatic-newsletters/{id}:
    parameters:
      - $ref: "#/components/parameters/AutomaticNewsletterSlug"
    get:
      tags: [Automatic newsletters]
      summary: Get an automatic newsletter
      responses:
        "200":
          description: The requested automatic newsletter.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomaticNewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Automatic newsletters]
      summary: Update an automatic newsletter
      description: |
        Send only the fields you want to change. The feed is re-fetched only
        when `feed_url` changes. Renaming the resource updates its slug;
        subsequent calls must use the new slug.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AutomaticNewsletterWriteRequest" }
      responses:
        "200":
          description: The updated automatic newsletter.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomaticNewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Automatic newsletters]
      summary: Delete an automatic newsletter
      description: Cascades to the feed-entry audit trail. Newsletters that have already been produced are kept untouched.
      responses:
        "204":
          description: The automatic newsletter has been deleted.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /automatic-newsletters/{id}/pause:
    parameters:
      - $ref: "#/components/parameters/AutomaticNewsletterSlug"
    post:
      tags: [Automatic newsletters]
      summary: Pause an automatic newsletter
      description: Stops polling the feed until `resume` is called. Idempotent.
      responses:
        "200":
          description: The automatic newsletter, now paused.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomaticNewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /automatic-newsletters/{id}/resume:
    parameters:
      - $ref: "#/components/parameters/AutomaticNewsletterSlug"
    post:
      tags: [Automatic newsletters]
      summary: Resume an automatic newsletter
      description: |
        Re-enables polling and resets `consecutive_failures` to `0`. Use this
        endpoint to recover a feed that was auto-paused after repeated
        failures.
      responses:
        "200":
          description: The automatic newsletter, now active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomaticNewsletterResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /automatic-newsletters/validate:
    post:
      tags: [Automatic newsletters]
      summary: Validate a feed URL
      description: |
        Fetches the supplied URL and returns the parsed feed metadata, or
        `422 validation_failed` if the URL is malformed or unreachable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [feed_url]
              properties:
                feed_url:
                  type: string
                  format: uri
                  example: "https://example.com/diary.xml"
      responses:
        "200":
          description: Parsed feed metadata.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FeedValidationResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhooks
      description: |
        Returns a paginated list of webhooks for the authenticated account.
        Requires the `webhooks` plan feature. The `signing_secret` is
        **never** returned by this endpoint; only `signing_secret_preview`
        (the last four characters) is included so callers can correlate the
        secret they captured at creation time with the webhook record.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
        - name: sort
          in: query
          description: Sort order.
          schema:
            type: string
            enum: [name_asc, name_desc, created_asc, created_desc]
            default: created_desc
      responses:
        "200":
          description: A page of webhooks.
          headers:
            Link: { $ref: "#/components/headers/Link" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookListResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/PaginationOutOfRange" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Webhooks]
      summary: Create a webhook
      description: |
        Creates a new webhook. Requires the `webhooks` plan feature and `write`
        scope. The response includes the newly minted `signing_secret` (a
        64-character hex HMAC-SHA256 key). **This is the only time the secret
        is returned in full** — capture it immediately, or use
        `POST /webhooks/{id}/rotate-signing-secret` to mint a new one.
        Subsequent reads expose only `signing_secret_preview`. Requests with
        unknown values in `event_types` are rejected with
        `error.details[].code = "invalid"`. Hitting the per-account limit
        (`MAX_PER_USER = 10`) returns `error.details[].code = "limit_reached"`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookCreateRequest" }
      responses:
        "201":
          description: The newly created webhook, including the full `signing_secret`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{id}:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
    get:
      tags: [Webhooks]
      summary: Get a webhook
      description: |
        Returns a single webhook. The full `signing_secret` is **not** included;
        only `signing_secret_preview` is returned. To rotate the secret use
        `POST /webhooks/{id}/rotate-signing-secret`.
      responses:
        "200":
          description: The requested webhook.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Webhooks]
      summary: Update a webhook
      description: |
        Send only the fields you want to change. Omitting `event_types` keeps
        the current set; supplying `event_types` replaces it entirely (a full
        replacement, not a delta). Re-enabling a webhook by setting
        `enabled: true` resets `consecutive_failures` to `0`. The response
        does **not** include the `signing_secret`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookUpdateRequest" }
      responses:
        "200":
          description: The updated webhook.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      description: |
        Permanently deletes the webhook and its event subscriptions. In-flight
        deliveries already enqueued continue to run but no new events will be
        dispatched.
      responses:
        "204":
          description: The webhook has been deleted.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{id}/rotate-signing-secret:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
    post:
      tags: [Webhooks]
      summary: Rotate a webhook's signing secret
      description: |
        Mints a new `signing_secret` and returns it in the response. The
        previous secret is invalidated immediately. **The new secret is only
        returned by this call** — capture it before moving on; subsequent
        reads expose only `signing_secret_preview`.
      responses:
        "200":
          description: The webhook with its new `signing_secret`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /newsletters/attachments:
    post:
      tags: [Attachments]
      summary: Upload an attachment
      description: |
        Upload a file and receive an opaque id that can be used inside a
        newsletter's `message` HTML as
        `<attachment id="att_…"></attachment>`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: Up to 20 MB. Allowed types are PDF, JPEG, PNG, GIF, WebP, MP3, MP4 audio, MP4 video, MPEG video.
      responses:
        "201":
          description: Attachment metadata. Use `data.id` in newsletter HTML.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AttachmentResponse" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Opaque
      description: |
        Supply an API token in the `Authorization` header as
        `Authorization: Bearer <token>`. Tokens carry either the `read` or
        `write` scope; mutating endpoints require `write`.

  parameters:
    Page:
      name: page
      in: query
      description: 1-based page number.
      schema: { type: integer, minimum: 1, default: 1 }
    PerPage:
      name: per_page
      in: query
      description: Items per page. Capped at 100.
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    SubscriberId:
      name: id
      in: path
      required: true
      description: Opaque hash of the subscriber's email address.
      schema: { type: string }
      example: "c3f9b6..."
    ImportId:
      name: id
      in: path
      required: true
      description: Subscriber import id.
      schema: { type: string }
      example: "imp_abc123"
    ThemeId:
      name: id
      in: path
      required: true
      description: Theme id.
      schema: { type: string }
      example: "thm_abc123"
    NewsletterId:
      name: id
      in: path
      required: true
      description: Newsletter slug.
      schema: { type: string }
      example: "tour-announcement"
    AutomaticNewsletterSlug:
      name: id
      in: path
      required: true
      description: Automatic newsletter slug, derived from its name.
      schema: { type: string }
      example: "studio-diary"
    WebhookId:
      name: id
      in: path
      required: true
      description: Webhook integer id.
      schema: { type: integer }
      example: 42

  headers:
    Link:
      description: |
        RFC 5988 navigation links — `first`, `prev`, `next`, and `last`, as
        applicable for the current page.
      schema: { type: string }

  requestBodies:
    BackgroundImageUpload:
      required: true
      content:
        multipart/form-data:
          schema:
            type: object
            required: [background_image]
            properties:
              background_image:
                type: string
                format: binary
                description: GIF, JPEG, PNG, or WebP image up to 10 MiB.

  responses:
    Unauthenticated:
      description: The access token is missing, invalid, revoked, or expired.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              code: unauthenticated
              message: "Access token is missing or invalid"
              details: []
            meta: { request_id: "req_abc123" }
    Forbidden:
      description: |
        The token is valid but does not permit this action (insufficient scope,
        suspended account, or a plan feature is required). Plan-gated endpoints
        return `error.code = "plan_feature_required"`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              code: forbidden
              message: "Not permitted"
              details: []
            meta: { request_id: "req_abc123" }
    NotFound:
      description: The requested resource does not exist or is not owned by the authenticated account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: not_found, message: "Resource not found", details: [] }
            meta: { request_id: "req_abc123" }
    PaginationOutOfRange:
      description: The `page` query parameter is outside the available range.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: not_found, message: "Requested page is out of range", details: [] }
            meta: { request_id: "req_abc123" }
    Conflict:
      description: The resource is in a state that prevents the requested operation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: conflict, message: "Newsletter has already been sent", details: [] }
            meta: { request_id: "req_abc123" }
    PayloadTooLarge:
      description: The request body exceeds the endpoint's size limit.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: payload_too_large, message: "File is too large", details: [] }
            meta: { request_id: "req_abc123" }
    UnsupportedMediaType:
      description: The uploaded file's content type is not permitted for this endpoint.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: unsupported_media_type, message: "Unsupported media type", details: [] }
            meta: { request_id: "req_abc123" }
    ValidationFailed:
      description: The request body or query parameters failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              code: validation_failed
              message: "Email address is invalid"
              details:
                - { field: email_address, code: invalid_format }
            meta: { request_id: "req_abc123" }
    Unprocessable:
      description: The request was understood but cannot be completed in the resource's current state.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: unprocessable, message: "Newsletter has no recipients", details: [] }
            meta: { request_id: "req_abc123" }
    RateLimited:
      description: The rate limit for this token has been exceeded.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: rate_limited, message: "Too many requests", details: [] }
            meta: { request_id: "req_abc123" }
    InternalError:
      description: An unexpected error occurred. BandTools has been notified.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: internal_error, message: "Something went wrong", details: [] }
            meta: { request_id: "req_abc123" }
    PageDesignOk:
      description: The page design.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/PageDesignResponse" }

  schemas:
    Meta:
      type: object
      required: [request_id]
      properties:
        request_id:
          type: string
          example: "req_abc123"

    PaginationMeta:
      type: object
      required: [page, per_page, total, total_pages, next_page, prev_page]
      properties:
        page: { type: integer, example: 1 }
        per_page: { type: integer, example: 25 }
        total: { type: integer, example: 412 }
        total_pages: { type: integer, example: 17 }
        next_page:
          type: [integer, "null"]
          example: 2
        prev_page:
          type: [integer, "null"]
          example: null

    PaginatedMeta:
      allOf:
        - $ref: "#/components/schemas/Meta"
        - type: object
          required: [pagination]
          properties:
            pagination: { $ref: "#/components/schemas/PaginationMeta" }

    Error:
      type: object
      required: [code, message, details]
      properties:
        code:
          type: string
          description: Canonical machine-readable error code.
          example: validation_failed
        message:
          type: string
          example: "Email address is invalid"
        details:
          type: array
          items:
            type: object
            properties:
              field: { type: string, example: email_address }
              code: { type: string, example: invalid_format }

    ErrorResponse:
      type: object
      required: [error, meta]
      properties:
        error: { $ref: "#/components/schemas/Error" }
        meta: { $ref: "#/components/schemas/Meta" }

    Subscriber:
      type: object
      required:
        - id
        - email_address
        - confirmed
        - source
        - subscribed_at
        - newsletter_name
      properties:
        id:
          type: string
          description: Opaque hash of the subscriber's email address.
          example: "c3f9b6..."
        email_address:
          type: string
          format: email
          example: "fan@example.com"
        confirmed:
          type: boolean
          example: true
        confirmed_at:
          type: [string, "null"]
          format: date-time
          example: "2026-04-22T12:00:00Z"
        source:
          type: string
          enum: [embed_code, file_import, subscribe_page, user_setting, manual_entry]
          example: manual_entry
        subscribed_at:
          type: string
          format: date-time
          example: "2026-04-22T12:00:00Z"
        newsletter_name:
          type: string
          example: "My Newsletter"

    SubscriberCreateRequest:
      oneOf:
        - type: object
          required: [email_address]
          properties:
            email_address:
              type: string
              format: email
              example: "fan@example.com"
        - type: object
          required: [subscriber]
          properties:
            subscriber:
              type: object
              required: [email_address]
              properties:
                email_address:
                  type: string
                  format: email
                  example: "fan@example.com"

    SubscriberResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/Subscriber" }
        meta: { $ref: "#/components/schemas/Meta" }

    SubscriberListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Subscriber" }
        meta: { $ref: "#/components/schemas/PaginatedMeta" }

    SubscriberImport:
      type: object
      required: [id, status, filename, created_at]
      properties:
        id:
          type: string
          example: "imp_abc123"
        status:
          type: string
          enum: [processing, completed, error]
          example: processing
        imported_count:
          type: [integer, "null"]
          example: null
        skipped_count:
          type: [integer, "null"]
          example: null
        failed_email_addresses:
          type: array
          items: { type: string, format: email }
        filename:
          type: [string, "null"]
          example: "subscribers.csv"
        error:
          type: [string, "null"]
          example: null
        created_at:
          type: string
          format: date-time
          example: "2026-04-22T12:00:00Z"
        completed_at:
          type: [string, "null"]
          format: date-time
          example: null

    SubscriberImportResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/SubscriberImport" }
        meta: { $ref: "#/components/schemas/Meta" }

    Plan:
      type: object
      required: [name, description]
      properties:
        name:
          type: string
          example: artist
        description:
          type: string
          example: Artist

    AccountPicture:
      type: object
      required: [content_type, filename, byte_size, url]
      properties:
        content_type:
          type: string
          example: "image/png"
        filename:
          type: string
          example: "avatar.png"
        byte_size:
          type: integer
          example: 42384
        url:
          type: string
          format: uri
          example: "https://bandtools.app/api/v1/account/picture"

    Account:
      type: object
      required:
        - id
        - name
        - email_address
        - username
        - verified
        - plan
        - features
        - newsletters_count
        - created_at
      properties:
        id: { type: string, example: "testusertwo" }
        name: { type: string, example: "Test User Two" }
        email_address: { type: string, format: email, example: "two@example.com" }
        username: { type: string, example: "testusertwo" }
        website_url:
          type: [string, "null"]
          format: uri
          example: "https://example.com"
        verified: { type: boolean, example: true }
        plan: { $ref: "#/components/schemas/Plan" }
        features:
          type: object
          description: |
            Plan feature map. Keys are feature slugs; values are `true` for
            presence-only features or an integer for valued limits. Absent keys
            mean the account does not have that feature.
          additionalProperties:
            oneOf:
              - type: boolean
              - type: integer
          example:
            automatic_newsletters: true
            duplicate_newsletter: true
            subscriber_limit: 1000
            unlimited_newsletters: true
        newsletters_count: { type: integer, example: 0 }
        picture:
          oneOf:
            - $ref: "#/components/schemas/AccountPicture"
            - type: "null"
        created_at:
          type: string
          format: date-time
          example: "2026-01-02T12:00:00Z"
        last_signed_in_at:
          type: [string, "null"]
          format: date-time
          example: "2026-04-21T08:12:34Z"

    AccountResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/Account" }
        meta: { $ref: "#/components/schemas/Meta" }

    AccountWrite:
      type: object
      description: |
        Only include the fields you want to change.

        `email_address` is intentionally not writeable via the API. Changing
        the account recovery channel is restricted to the sudo-protected web
        UI to prevent a leaked API token from triggering an account-takeover
        flow via password reset.
      properties:
        name: { type: string, example: "Test User Two" }
        username: { type: string, example: "testusertwo" }
        website_url:
          type: [string, "null"]
          format: uri

    AccountPictureResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/AccountPicture" }
        meta: { $ref: "#/components/schemas/Meta" }

    AccountSettings:
      type: object
      description: App-level preferences for the BandTools dashboard.
      properties:
        blog_subscription: { type: boolean, example: false }
        dark_mode: { type: boolean, example: false }
        draft_newsletters_order:
          type: integer
          enum: [0, 1, 2, 3]
          example: 2
          description: |
            Draft newsletters table sort order.
            `0` Subject A→Z, `1` Subject Z→A, `2` Most recently saved first (default), `3` Oldest saved first.
        items_per_page: { type: integer, minimum: 5, maximum: 50, example: 10 }
        sent_newsletters_order:
          type: integer
          enum: [0, 1, 2, 3]
          example: 2
          description: |
            Sent newsletters table sort order.
            `0` Subject A→Z, `1` Subject Z→A, `2` Most recently sent first (default), `3` Oldest sent first.
        subscribers_order:
          type: integer
          enum: [0, 1, 2, 3]
          example: 2
          description: |
            Subscribers table sort order.
            `0` Email address A→Z, `1` Email address Z→A, `2` Most recently subscribed first (default), `3` Oldest subscribed first.

    AccountSettingsResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/AccountSettings" }
        meta: { $ref: "#/components/schemas/Meta" }

    AccountNewsletterSettings:
      type: object
      properties:
        newsletter_name:
          type: string
          example: "My Newsletter"
        newsletter_description:
          type: string
          example: "<div>Stories from the studio.</div>"
        block_ai_user_agents:
          type: boolean
          example: false
        subscribe_notifications:
          type: boolean
          example: true
        unsubscribe_notifications:
          type: boolean
          example: true

    AccountNewsletterSettingsResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/AccountNewsletterSettings" }
        meta: { $ref: "#/components/schemas/Meta" }

    PageTheme:
      type: object
      required:
        - id
        - name
        - system
        - in_use
        - created_at
        - updated_at
      properties:
        id: { type: string, example: "thm_abc123" }
        name: { type: string, example: "My Theme" }
        system: { type: boolean, example: false }
        in_use: { type: boolean, example: true }
        body_background_colour: { type: string, example: "#ffffff" }
        body_font_family: { type: string, example: "Inter" }
        body_font_size: { type: integer, example: 16 }
        body_text_colour: { type: string, example: "#111111" }
        heading_background_colour: { type: string, example: "#ffffff" }
        heading_font_family: { type: string, example: "Inter" }
        heading_font_size: { type: integer, example: 32 }
        heading_text_colour: { type: string, example: "#111111" }
        link_colour: { type: string, example: "#0066cc" }
        link_style: { type: integer, example: 0 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PageThemeWrite:
      type: object
      description: All fields are optional on update; `name` is required on create.
      properties:
        name: { type: string, example: "My Theme" }
        body_background_colour: { type: string, example: "#ffffff" }
        body_font_family: { type: string, example: "Inter" }
        body_font_size: { type: integer, example: 16 }
        body_text_colour: { type: string, example: "#111111" }
        heading_background_colour: { type: string, example: "#ffffff" }
        heading_font_family: { type: string, example: "Inter" }
        heading_font_size: { type: integer, example: 32 }
        heading_text_colour: { type: string, example: "#111111" }
        link_colour: { type: string, example: "#0066cc" }
        link_style: { type: integer, example: 0 }

    PageThemeResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/PageTheme" }
        meta: { $ref: "#/components/schemas/Meta" }

    PageThemeListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/PageTheme" }
        meta: { $ref: "#/components/schemas/PaginatedMeta" }

    PageDesign:
      type: object
      description: |
        Fields present depend on the page type: `content` appears on
        subscribe/confirmation/unsubscribe pages; `heading` appears on
        confirmation and unsubscribe pages.
      properties:
        body_background_colour: { type: string, example: "#ffffff" }
        body_text_colour: { type: string, example: "#111111" }
        body_font_size: { type: integer, example: 16 }
        heading_background_colour: { type: string, example: "#ffffff" }
        heading_text_colour: { type: string, example: "#111111" }
        heading_font_size: { type: integer, example: 32 }
        link_colour: { type: string, example: "#0066cc" }
        link_style: { type: integer, example: 0 }
        link_style_label: { type: string, example: "Default" }
        body_font: { type: string, example: "Inter" }
        heading_font: { type: string, example: "Inter" }
        page_theme_id:
          type: [string, "null"]
          example: null
        background_image_url:
          type: [string, "null"]
          format: uri
        content:
          type: string
          description: HTML body. Present on subscribe/confirmation/unsubscribe pages.
        heading:
          type: string
          description: HTML heading. Present on confirmation/unsubscribe pages.
        updated_at:
          type: string
          format: date-time

    PageDesignWrite:
      type: object
      description: Only include the fields you want to change.
      properties:
        body_background_colour: { type: string }
        body_text_colour: { type: string }
        body_font_size: { type: integer }
        heading_background_colour: { type: string }
        heading_text_colour: { type: string }
        heading_font_size: { type: integer }
        link_colour: { type: string }
        link_style: { type: integer }
        body_font: { type: string }
        heading_font: { type: string }
        page_theme_id:
          type: [string, "null"]
        content: { type: string }
        heading: { type: string }

    PageDesignResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/PageDesign" }
        meta: { $ref: "#/components/schemas/Meta" }

    ConfirmationEmail:
      type: object
      required: [subject, message, updated_at]
      properties:
        subject:
          type: string
          example: "Please confirm your subscription"
        message:
          type: string
          description: HTML body of the confirmation email.
        updated_at:
          type: string
          format: date-time

    ConfirmationEmailWrite:
      type: object
      properties:
        subject: { type: string }
        message: { type: string }

    ConfirmationEmailResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/ConfirmationEmail" }
        meta: { $ref: "#/components/schemas/Meta" }

    Newsletter:
      type: object
      required:
        - id
        - subject
        - status
        - reply_to_enabled
        - public
        - pinned
        - lock_version
        - created_at
        - updated_at
      properties:
        id:
          type: string
          description: Newsletter slug, used as the path identifier.
          example: "tour-announcement"
        subject:
          type: string
          example: "Tour announcement"
        status:
          type: string
          enum: [draft, scheduled, sent]
          example: draft
        reply_to_enabled: { type: boolean, example: true }
        public: { type: boolean, example: false }
        pinned:
          type: boolean
          description: |
            Whether this newsletter is pinned to the top of the user's public
            archive. At most one pinned newsletter per user; pinning a different
            newsletter automatically unpins the previous one.
          example: false
        lock_version:
          type: integer
          description: Optimistic locking counter. Pass this on PATCH to detect concurrent edits.
          example: 0
        scheduled_for:
          type: [string, "null"]
          format: date-time
        sent_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        role:
          type: string
          enum: [owner, collaborator]
          description: Present on single-resource responses when authenticated.
        collaborators_count:
          type: integer
          description: Owner-only. Number of pending + accepted collaborators.
        lock:
          type: [object, "null"]
          description: Present on single-resource responses. Non-null when a lock is active.
          properties:
            holder:
              type: object
              properties:
                username: { type: string }
                name: { type: string }
            locked_until:
              type: string
              format: date-time
        owner:
          type: object
          description: Present on shared-newsletters list responses.
          properties:
            username: { type: string }
            name: { type: string }
        message:
          type: string
          description: |
            HTML body as stored. Present on single-resource responses. May
            include `<attachment id="att_…"></attachment>` placeholders.
        html:
          type: string
          description: Rendered HTML body. Present on single-resource responses.

    NewsletterWrite:
      type: object
      description: Only include the fields you want to change.
      properties:
        subject:
          type: string
          example: "Tour announcement"
        message:
          type: string
          description: HTML body. Can include `<attachment id="att_…">` references.
        reply_to_enabled:
          type: boolean
          example: true
        public:
          type: boolean
          example: false
        lock_version:
          type: integer
          description: Pass the lock_version from a previous GET to detect concurrent edits.

    NewsletterResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/Newsletter" }
        meta: { $ref: "#/components/schemas/Meta" }

    NewsletterListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Newsletter" }
        meta: { $ref: "#/components/schemas/PaginatedMeta" }

    Attachment:
      type: object
      required: [id, content_type, byte_size, filename]
      properties:
        id:
          type: string
          example: "att_BAhmNF..."
        content_type:
          type: string
          example: "image/jpeg"
        byte_size:
          type: integer
          example: 142112
        filename:
          type: string
          example: "cover.jpg"

    AttachmentResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/Attachment" }
        meta: { $ref: "#/components/schemas/Meta" }

    AutomaticNewsletter:
      type: object
      required:
        - id
        - slug
        - name
        - feed_url
        - behaviour
        - frequency
        - use_post_title_as_subject
        - include_featured_image
        - public
        - reply_to_enabled
        - paused
        - consecutive_failures
        - disabled_by_failures
        - created_at
        - updated_at
      properties:
        id:
          type: string
          description: Automatic newsletter slug, used as the path identifier (matches `slug`).
          example: "studio-diary"
        slug:
          type: string
          description: Slug derived from `name` (read-only).
          example: "studio-diary"
        name:
          type: string
          example: "Studio diary"
        feed_url:
          type: string
          format: uri
          example: "https://example.com/diary.xml"
        behaviour:
          type: string
          description: How matching feed entries are turned into newsletters.
          enum: [draft, auto_send]
          example: draft
        frequency:
          type: string
          description: How often the feed is polled.
          enum: [immediate, daily, weekly]
          example: daily
        use_post_title_as_subject:
          type: boolean
          example: true
        include_featured_image:
          type: boolean
          example: true
        public:
          type: boolean
          description: Whether produced newsletters are public on the archive page by default.
          example: false
        reply_to_enabled:
          type: boolean
          description: Whether produced newsletters accept replies by email.
          example: true
        paused:
          type: boolean
          description: Whether feed polling is currently paused.
          example: false
        consecutive_failures:
          type: integer
          description: How many consecutive fetch failures have occurred.
          example: 0
        disabled_by_failures:
          type: boolean
          description: True when the feed has been auto-disabled after repeated failures.
          example: false
        feed_title:
          type: [string, "null"]
          example: "Studio diary"
        site_url:
          type: [string, "null"]
          format: uri
          example: "https://example.com/"
        last_entry_title:
          type: [string, "null"]
          example: "Day 4: Mixing the bridge"
        last_entry_url:
          type: [string, "null"]
          format: uri
          example: "https://example.com/diary/day-4"
        last_checked_at:
          type: [string, "null"]
          format: date-time
        last_newsletter_created_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    AutomaticNewsletterWrite:
      type: object
      description: Only include the fields you want to change.
      properties:
        name:
          type: string
          example: "Studio diary"
        feed_url:
          type: string
          format: uri
          example: "https://example.com/diary.xml"
        behaviour:
          type: string
          enum: [draft, auto_send]
        frequency:
          type: string
          enum: [immediate, daily, weekly]
        use_post_title_as_subject:
          type: boolean
        include_featured_image:
          type: boolean
        public:
          type: boolean
        reply_to_enabled:
          type: boolean

    AutomaticNewsletterWriteRequest:
      type: object
      required: [automatic_newsletter]
      properties:
        automatic_newsletter:
          $ref: "#/components/schemas/AutomaticNewsletterWrite"

    AutomaticNewsletterResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/AutomaticNewsletter" }
        meta: { $ref: "#/components/schemas/Meta" }

    AutomaticNewsletterListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/AutomaticNewsletter" }
        meta: { $ref: "#/components/schemas/PaginatedMeta" }

    WebhookEventType:
      type: string
      description: One of the supported webhook event types.
      enum:
        - newsletter.scheduled
        - newsletter.sent
        - subscriber.confirmed
        - subscriber.created
        - subscriber.removed
        - subscriber.unsubscribed
      example: newsletter.sent

    Webhook:
      type: object
      required:
        - id
        - name
        - url
        - enabled
        - event_types
        - consecutive_failures
        - signing_secret_preview
        - created_at
        - updated_at
      properties:
        id:
          type: integer
          description: Webhook id, used as the path identifier.
          example: 42
        name:
          type: string
          description: Human-friendly label, 4-100 characters, unique per account.
          example: "Production sync"
        url:
          type: string
          format: uri
          description: HTTPS URL the event payload is POSTed to. Up to 2048 characters; unique per account.
          example: "https://hooks.example.com/bandtools"
        enabled:
          type: boolean
          description: Whether the webhook is currently dispatching events. Setting this back to `true` resets `consecutive_failures` to `0`.
          example: true
        event_types:
          type: array
          description: Event types this webhook is subscribed to. Returned sorted alphabetically.
          items: { $ref: "#/components/schemas/WebhookEventType" }
          example: [newsletter.sent, subscriber.confirmed]
        consecutive_failures:
          type: integer
          description: Consecutive failed deliveries. Reaching `MAX_CONSECUTIVE_FAILURES` (10) auto-disables the webhook.
          example: 0
        signing_secret_preview:
          type: [string, "null"]
          description: Last four characters of the signing secret, prefixed with `...`. Use this to confirm which secret a record holds without re-exposing the full key.
          example: "...a1b2"
        signing_secret:
          type: string
          description: |
            64-character hexadecimal HMAC-SHA256 signing secret. Only present
            in responses to `POST /webhooks` (creation) and
            `POST /webhooks/{id}/rotate-signing-secret`. Other endpoints omit
            this field.
          example: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    WebhookCreate:
      type: object
      required: [name, url, event_types]
      properties:
        name:
          type: string
          minLength: 4
          maxLength: 100
          example: "Production sync"
        url:
          type: string
          format: uri
          maxLength: 2048
          description: Must be an HTTPS URL.
          example: "https://hooks.example.com/bandtools"
        enabled:
          type: boolean
          default: true
          example: true
        event_types:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/WebhookEventType" }
          example: [newsletter.sent, subscriber.confirmed]

    WebhookUpdate:
      type: object
      description: |
        Send only the fields you want to change. Supplying `event_types`
        replaces the current set; omitting it keeps the existing
        subscriptions.
      properties:
        name:
          type: string
          minLength: 4
          maxLength: 100
        url:
          type: string
          format: uri
          maxLength: 2048
        enabled:
          type: boolean
        event_types:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/WebhookEventType" }

    WebhookCreateRequest:
      type: object
      required: [webhook]
      properties:
        webhook:
          $ref: "#/components/schemas/WebhookCreate"

    WebhookUpdateRequest:
      type: object
      required: [webhook]
      properties:
        webhook:
          $ref: "#/components/schemas/WebhookUpdate"

    WebhookResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/Webhook" }
        meta: { $ref: "#/components/schemas/Meta" }

    WebhookListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Webhook" }
        meta: { $ref: "#/components/schemas/PaginatedMeta" }

    Collaborator:
      type: object
      required:
        - id
        - email_address
        - status
        - invited_at
      properties:
        id:
          type: integer
          example: 1
        email_address:
          type: string
          format: email
          example: "collaborator@example.com"
        status:
          type: string
          enum: [pending, accepted]
          example: accepted
        name:
          type: [string, "null"]
          description: The accepted collaborator's display name, or null if pending.
          example: "Jane Doe"
        invited_at:
          type: string
          format: date-time
        accepted_at:
          type: [string, "null"]
          format: date-time
        last_edited_at:
          type: [string, "null"]
          format: date-time

    CollaboratorResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/Collaborator" }
        meta: { $ref: "#/components/schemas/Meta" }

    CollaboratorListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Collaborator" }
        meta: { $ref: "#/components/schemas/Meta" }

    LockData:
      type: object
      required: [acquired]
      properties:
        acquired:
          type: boolean
          example: true
        lock_holder:
          type: [string, "null"]
          example: "Jane Doe"
        locked_until:
          type: [string, "null"]
          format: date-time

    LockResponse:
      type: object
      required: [data, meta]
      properties:
        data: { $ref: "#/components/schemas/LockData" }
        meta: { $ref: "#/components/schemas/Meta" }

    FeedValidationResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: object
          required: [valid]
          properties:
            valid:
              type: boolean
              example: true
            feed_title:
              type: [string, "null"]
              example: "Studio diary"
            site_url:
              type: [string, "null"]
              format: uri
              example: "https://example.com/"
            latest_item_title:
              type: [string, "null"]
              example: "Day 4: Mixing the bridge"
            latest_item_url:
              type: [string, "null"]
              format: uri
              example: "https://example.com/diary/day-4"
        meta: { $ref: "#/components/schemas/Meta" }
