# SPDX-License-Identifier: Apache-2.0
openapi: 3.0.3
info:
  title: Araldo API
  version: v1
  license:
    name: Apache-2.0
    url: https://www.apache.org/licenses/LICENSE-2.0
  description: |
    Schedule and publish posts to every platform from your product.

    **Authentication.** Send an API key as a bearer token:
    `Authorization: Bearer ald_test_…`. Test keys (`ald_test_`) only ever
    reach sandbox channels; live keys (`ald_live_`) reach real accounts.

    **Scopes.** A key with no scopes listed holds every integration scope;
    one with scopes holds those it lists. The administrative scopes
    `keys:write`, `posts:approve`, `audit:read` and `ads:write` are held
    only when listed by name, and only a member (the dashboard, or
    `araldo admin apikeys create`) can create a key with them.

    **Idempotency.** Every `POST` accepts an `Idempotency-Key` header. A
    retry with the same key and body returns the first response (with
    `Idempotent-Replayed: true`) for 24 hours. Only a request that took
    effect (2xx) keeps its key: after an error, nothing happened, so the
    same key can be sent again with a corrected request.

    **Strict parameters.** An unknown body field or query parameter is a
    `400 parameter_unknown`, so a typo never passes as a filter. Paths take
    IDs (and a brand's slug); find a template by key with
    `GET /v1/templates?brand=…&key=…`.

    **Errors** are RFC 9457 problem details with a stable `code`, the
    `param` at fault, and every validation problem in `errors`.

    **Lists** page by cursor: `limit`, `starting_after`, `ending_before`,
    and `has_more` in the response.

    **Events and webhooks.** Every change of interest becomes an event,
    listed at `/v1/events` and delivered to your webhook endpoints, signed
    with `Araldo-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body">`.
servers:
  - url: /
security:
  - apiKey: []
tags:
  - name: Brands
  - name: Channels
  - name: Templates
  - name: Media
  - name: Posts
  - name: Engagement
  - name: Ads
  - name: Analytics
  - name: Newsletters
  - name: Reports
  - name: Events
  - name: Webhooks
  - name: API keys
  - name: Sign-in
    description: How the araldo CLI signs a person in (device sign-in and user tokens).
  - name: Members
    description: The org's members and settings. A person's user token only; never an API key.
  - name: Notifications
    description: What Araldo tells a person, in the dashboard and by email. A person's user token only; never an API key.
  - name: Audit
  - name: Platforms
  - name: MCP
  - name: Operator
    description: |
      The install's operator API (ADR 0031), for the service that runs a
      hosted install: create and manage orgs from outside. Operator keys
      only (`ald_op_…`, from `araldo admin operator-keys create`).

paths:
  /v1/openapi.yaml:
    get:
      summary: This contract
      security: []
      tags: [Platforms]
      responses:
        "200":
          description: The OpenAPI document.
          content:
            application/yaml:
              schema: { type: string }

  /v1/platforms:
    get:
      summary: List platforms and their rules
      description: Length limits, how length is counted, threads and media rules for every platform. In live mode, also the fields needed to connect a channel.
      tags: [Platforms]
      responses:
        "200":
          description: Platforms.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Platform" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/brands:
    get:
      summary: List brands
      tags: [Brands]
      responses:
        "200":
          description: Brands.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Brand" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Create a brand
      description: New brands get weekday publishing slots at 09:00 and 13:00 in their time zone.
      tags: [Brands]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BrandInput" }
      responses:
        "201":
          description: The brand.
          content: { application/json: { schema: { $ref: "#/components/schemas/Brand" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/brands/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: A brand ID (`brand_…`) or slug.
        schema: { type: string }
    get:
      summary: Retrieve a brand
      tags: [Brands]
      responses:
        "200":
          description: The brand.
          content: { application/json: { schema: { $ref: "#/components/schemas/Brand" } } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Update a brand
      tags: [Brands]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BrandInput" }
      responses:
        "200":
          description: The brand.
          content: { application/json: { schema: { $ref: "#/components/schemas/Brand" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/brands/{id}/email_theme:
    parameters:
      - name: id
        in: path
        required: true
        description: A brand ID (`brand_…`) or slug.
        schema: { type: string }
    post:
      summary: Set a brand's email theme
      description: |
        The brand's look in newsletters: a logo from its media library, an
        accent color for links and buttons (dark enough to read on white,
        4.5:1), a footer line, and the postal address anti-spam laws
        require in every newsletter; an issue cannot be scheduled without
        it. Needs `brands:write`.
      tags: [Newsletters]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                logo: { type: string, nullable: true, description: "A media ID of the brand's, or null for the brand's name." }
                accent: { type: string, example: "#1d4ed8", description: "#rrggbb, or empty for the default." }
                postal_address: { type: string, maxLength: 300, example: "1 Main St\nDenver, CO 80202" }
                footer: { type: string, maxLength: 300, example: You signed up at araldo.dev. }
      responses:
        "200":
          description: The brand.
          content: { application/json: { schema: { $ref: "#/components/schemas/Brand" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/channels:
    get:
      summary: List channels
      description: Channels in the key's mode.
      tags: [Channels]
      parameters:
        - { name: brand, in: query, schema: { type: string }, description: Brand ID or slug. }
      responses:
        "200":
          description: Channels.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Channel" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Connect a channel
      description: |
        Araldo checks the credentials with the platform before saving them
        (encrypted). With a test key, only `provider: sandbox` works; give
        the platform it imitates in `emulates`.
      tags: [Channels]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [brand, provider]
              properties:
                brand: { type: string }
                provider: { type: string, example: sandbox }
                emulates: { type: string, example: bluesky, description: For sandbox channels. }
                fields:
                  type: object
                  additionalProperties: { type: string }
                  description: The platform's connect fields (see /v1/platforms).
      responses:
        "201":
          description: The channel.
          content: { application/json: { schema: { $ref: "#/components/schemas/Channel" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/channels/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Retrieve a channel
      tags: [Channels]
      responses:
        "200":
          description: The channel.
          content: { application/json: { schema: { $ref: "#/components/schemas/Channel" } } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Enable or disable a channel
      description: |
        A disabled channel keeps its credentials but takes no new posts. A
        channel that needs reauthorization is enabled by reconnecting it.
      tags: [Channels]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { type: string, enum: [active, disabled] }
      responses:
        "200":
          description: The channel.
          content: { application/json: { schema: { $ref: "#/components/schemas/Channel" } } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      summary: Disconnect a channel
      tags: [Channels]
      responses:
        "200":
          description: Deleted.
          content: { application/json: { schema: { $ref: "#/components/schemas/Deleted" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/channels/{id}/reconnect:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Reconnect a channel
      description: |
        Replaces a channel's credentials after the platform revoked them (the
        `channel.needs_reauth` event), checks them with the platform, and
        makes the channel active again. Send the secret fields; settings left
        out keep their current values.
      tags: [Channels]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                fields:
                  type: object
                  additionalProperties: { type: string }
                  description: The platform's connect fields (see /v1/platforms).
      responses:
        "200":
          description: The channel.
          content: { application/json: { schema: { $ref: "#/components/schemas/Channel" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/templates:
    get:
      summary: List templates
      tags: [Templates]
      parameters:
        - { name: brand, in: query, schema: { type: string }, description: Brand ID or slug. }
        - { name: key, in: query, schema: { type: string }, description: "Only the template with this key (unique within a brand)." }
      responses:
        "200":
          description: Templates (without their bodies).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Template" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Create a template
      tags: [Templates]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/TemplateSource"
                - type: object
                  required: [brand, key]
                  properties:
                    brand: { type: string }
                    key: { type: string, example: featured-build }
                    name: { type: string }
                    approval: { $ref: "#/components/schemas/TemplateApproval" }
      responses:
        "201":
          description: The template with version 1.
          content: { application/json: { schema: { $ref: "#/components/schemas/Template" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/templates/preview:
    post:
      summary: Preview a template
      description: Render a saved template (`brand` + `template`) or an unsaved one (the source fields) for each platform. Nothing is stored.
      tags: [Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/TemplateSource"
                - type: object
                  properties:
                    brand: { type: string }
                    template: { type: string, description: "key, key@version or template ID" }
                    data: { type: object, additionalProperties: true }
                    providers: { type: array, items: { type: string } }
      responses:
        "200":
          description: One rendition per platform.
          content: { application/json: { schema: { $ref: "#/components/schemas/Preview" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/templates/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Retrieve a template
      tags: [Templates]
      parameters:
        - { name: version, in: query, schema: { type: integer, minimum: 1 }, description: Default the latest. }
      responses:
        "200":
          description: The template and the version asked for.
          content: { application/json: { schema: { $ref: "#/components/schemas/Template" } } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Update a template's settings
      description: |
        Rename a template or change its approval rule, without making a new
        version. Only people who can approve posts may change `approval`, so
        API keys cannot.
      tags: [Templates]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                approval: { $ref: "#/components/schemas/TemplateApproval" }
      responses:
        "200":
          description: The template.
          content: { application/json: { schema: { $ref: "#/components/schemas/Template" } } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      summary: Delete a template
      description: Posts made from it keep their text.
      tags: [Templates]
      responses:
        "200":
          description: Deleted.
          content: { application/json: { schema: { $ref: "#/components/schemas/Deleted" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/templates/{id}/versions:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Save a new version
      description: Versions are immutable. Queued posts keep the text they were rendered with.
      tags: [Templates]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/TemplateSource"
                - type: object
                  properties:
                    name: { type: string }
      responses:
        "201":
          description: The template with its new version.
          content: { application/json: { schema: { $ref: "#/components/schemas/Template" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/media:
    get:
      summary: List media
      description: Media in the key's mode, newest first.
      tags: [Media]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - { name: brand, in: query, schema: { type: string }, description: Brand ID or slug. }
      responses:
        "200":
          description: Media.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Media" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Upload an image or a video
      description: |
        Stores a JPEG, PNG, GIF or WebP image of at most 16 MiB, or an MP4
        or QuickTime video, for posts to attach (`media` on a post).
        Upload the file as `multipart/form-data`, or send JSON with a
        `url` for Araldo to fetch (public addresses only). The type comes
        from the file's bytes. Each platform's own limits are checked when
        a post uses the file; an image too big for a platform is resized
        for it, never cropped (ADR 0027). Media no post uses is deleted
        after 24 hours. Needs the `posts:write` scope.

        Video needs the install's S3-compatible storage and is streamed
        into it, up to the install's limit (1 GiB unless configured). Its
        length, frame rate and codecs are read from its index; Araldo
        does not transcode, so export it as H.264 with AAC in an MP4. An
        `Idempotency-Key` makes Araldo hold the request in memory, which
        limits it to 16 MiB: upload a video without one.
      tags: [Media]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [brand, file]
              properties:
                brand: { type: string, description: Brand ID or slug. }
                file: { type: string, format: binary }
                alt: { type: string, maxLength: 1000, description: "Alt text, sent to every platform that takes it." }
          application/json:
            schema:
              type: object
              required: [brand, url]
              properties:
                brand: { type: string, description: Brand ID or slug. }
                url: { type: string, example: https://araldo.dev/images/release-0.5.0.png }
                alt: { type: string, maxLength: 1000 }
      responses:
        "201":
          description: The media.
          content: { application/json: { schema: { $ref: "#/components/schemas/Media" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/media/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Retrieve media
      tags: [Media]
      responses:
        "200":
          description: The media.
          content: { application/json: { schema: { $ref: "#/components/schemas/Media" } } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Update media
      description: Changes the alt text. The file itself never changes.
      tags: [Media]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [alt]
              properties:
                alt: { type: string, maxLength: 1000 }
      responses:
        "200":
          description: The media.
          content: { application/json: { schema: { $ref: "#/components/schemas/Media" } } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      summary: Delete media
      description: Only media no post uses can be deleted (otherwise `409 media_in_use`).
      tags: [Media]
      responses:
        "200":
          description: Deleted.
          content: { application/json: { schema: { $ref: "#/components/schemas/Deleted" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/media/{id}/content:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Download media by a signed link
      description: |
        Serves a media file without an API key when the link is signed and
        has not expired. Araldo makes these links (valid for an hour) for
        platforms that fetch images themselves, such as Threads and
        Instagram, and links without an expiry, signed for that purpose
        only, for images in newsletters, which are opened long after they
        are sent. There is no need to call it directly.
      security: []
      tags: [Media]
      parameters:
        - { name: expires, in: query, schema: { type: integer }, description: "Unix time the link stops working; absent on a newsletter's link." }
        - { name: for, in: query, schema: { type: string }, description: "The platform the image was resized for, which the link serves instead of the original (ADR 0027)." }
        - { name: signature, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: The file.
          content:
            image/*:
              schema: { type: string, format: binary }
        default: { $ref: "#/components/responses/Error" }

  /v1/posts:
    get:
      summary: List posts
      tags: [Posts]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - { name: brand, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/PostStatus" } }
        - name: metadata
          in: query
          style: deepObject
          explode: true
          schema: { type: object, additionalProperties: { type: string } }
          description: "Filter by metadata, e.g. metadata[build_id]=8812."
        - { name: q, in: query, schema: { type: string, maxLength: 200 }, description: "Posts whose text, on any channel, contains this (case insensitive)." }
      responses:
        "200":
          description: Posts, newest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Post" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Create a post
      description: |
        Renders the post for every channel, checks each platform's rules,
        and schedules it. The text is frozen now: what `/v1/posts/preview`
        showed is what gets published. A rule violation is a 422 listing
        every channel's problems.
      tags: [Posts]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PostInput" }
      responses:
        "201":
          description: The post.
          content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/posts/preview:
    post:
      summary: Preview a post
      description: Renders a post for each channel without saving it. Rule violations are listed per rendition (not as an error), so an agent can read them and fix its text.
      tags: [Posts]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PostInput" }
      responses:
        "200":
          description: One rendition per channel.
          content: { application/json: { schema: { $ref: "#/components/schemas/Preview" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/posts/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Retrieve a post
      tags: [Posts]
      responses:
        "200":
          description: The post and its targets.
          content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/posts/{id}/cancel:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Cancel a post
      description: Cancels every target not yet published or publishing.
      tags: [Posts]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses:
        "200":
          description: The post.
          content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/posts/{id}/reschedule:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Move a post
      description: |
        Moves a post that has not started publishing (`scheduled` or
        `pending_approval`, with no attempts yet) to another time, or swaps
        it with another such post of the same brand and mode. Approval is
        kept: an approved post stays approved.

        - `publish_at` a time, or `"now"`: a time on one of the brand's
          slots takes that slot, and fails with `409 slot_taken`, naming the
          post that holds it, when another post has it.
        - `publish_at: "next_slot"`: the brand's next free slot. A post
          waiting for approval goes back to taking its slot when approved.
        - `swap_with`: the two posts trade times, deadlines and slots in
          one step.

        `publish_by` defaults to the new `publish_at` + 24h. Emits
        `post.rescheduled`. Needs `posts:write`.
      tags: [Posts]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RescheduleInput" }
      responses:
        "200":
          description: The post.
          content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/posts/{id}/approve:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Approve a post
      description: |
        Approves a post waiting for review (`pending_approval`), which then
        publishes on schedule. Needs the explicit `posts:approve` scope. A
        key cannot review a post it created.

        A `next_slot` post takes the brand's next free slot now, so posts
        are slotted in the order they are approved, and `post.approved`
        carries the time. When every slot in the next 56 days is taken it
        fails with `409 slots_full`, and when no free slot comes before
        the post's `publish_by` with `409 no_slot_before_publish_by`; the
        post stays pending either way.
      tags: [Posts]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReviewInput" }
      responses:
        "200":
          description: The post.
          content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/posts/{id}/reject:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Reject a post
      description: Rejects a post waiting for review; it will not publish. Needs the explicit `posts:approve` scope.
      tags: [Posts]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReviewInput" }
      responses:
        "200":
          description: The post.
          content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/post_targets/{id}/retry:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Retry a target
      description: Queues a failed or `needs_attention` target again. For `needs_attention`, you assert the post did not go out.
      tags: [Posts]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses:
        "200":
          description: The post.
          content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/post_targets/{id}/mark_published:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Mark a target published
      description: For a `needs_attention` target that did go out.
      tags: [Posts]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                permalink: { type: string }
      responses:
        "200":
          description: The post.
          content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/post_targets/{id}/engagement:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: List a target's engagement readings
      description: |
        Every reading of a published target's engagement, oldest first.
        Readings happen 1 hour, 6 hours, 1 day, 3 days, 7 days and 30 days
        after publishing; the target's `engagement` holds the latest. In test
        mode the sandbox invents the numbers.
      tags: [Engagement]
      responses:
        "200":
          description: Readings.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/EngagementReading" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/post_targets/{id}/attempts:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: List a target's publish attempts
      description: Every try at publishing the target, oldest first, with the platform's error when it failed.
      tags: [Posts]
      responses:
        "200":
          description: Attempts.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/PublishAttempt" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/mcp:
    post:
      summary: MCP tools over HTTP
      description: |
        The Model Context Protocol endpoint (Streamable HTTP, stateless), so
        an AI assistant can use Araldo's tools by URL with an API key
        (ADR 0020): send one JSON-RPC 2.0 message, or a batch, and get its
        answer as JSON. Notifications get `202`. The tools run as the key,
        with its scopes, brand limit and rate limit, and are the same as
        `araldo mcp`'s: `initialize`, `tools/list`, then `tools/call`. A
        browser on another site is refused (`Origin`).
      tags: [MCP]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [jsonrpc, method]
              properties:
                jsonrpc: { type: string, enum: ["2.0"] }
                id: { description: Absent for a notification. }
                method: { type: string, example: tools/list }
                params: { type: object, additionalProperties: true }
      responses:
        "200":
          description: The JSON-RPC response (or responses, for a batch).
          content:
            application/json:
              schema:
                type: object
                properties:
                  jsonrpc: { type: string, enum: ["2.0"] }
                  id: {}
                  result: { type: object, additionalProperties: true }
                  error:
                    type: object
                    properties:
                      code: { type: integer }
                      message: { type: string }
        "202":
          description: A notification, accepted; no body.
        default: { $ref: "#/components/responses/Error" }

  /v1/ad_networks:
    get:
      summary: List ad networks
      description: |
        The ad networks this install can read (ADR 0023), with the fields
        needed to connect an account. Test mode has only `sandbox`, whose
        results are invented.
      tags: [Ads]
      responses:
        "200":
          description: Ad networks.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/AdNetwork" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/ad_accounts:
    get:
      summary: List ad accounts
      description: Ad accounts connected in the key's mode. Needs `ads:read`.
      tags: [Ads]
      parameters:
        - { name: brand, in: query, schema: { type: string }, description: Brand ID or slug. }
      responses:
        "200":
          description: Ad accounts.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/AdAccount" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Connect an ad account
      description: |
        Checks the credentials with the network, reads the account's name,
        currency and time zone, and stores the credentials encrypted. Its
        results are read soon after, then daily. Needs the explicit
        `ads:write` scope. Connecting spends nothing.
      tags: [Ads]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [brand, network]
              properties:
                brand: { type: string }
                network: { type: string, example: sandbox }
                fields:
                  type: object
                  additionalProperties: { type: string }
                  description: The network's connect fields (see /v1/ad_networks).
      responses:
        "201":
          description: The ad account.
          content: { application/json: { schema: { $ref: "#/components/schemas/AdAccount" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/ad_accounts/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Get an ad account
      description: Needs `ads:read`.
      tags: [Ads]
      responses:
        "200":
          description: The ad account.
          content: { application/json: { schema: { $ref: "#/components/schemas/AdAccount" } } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      summary: Disconnect an ad account
      description: Forgets the account, its credentials and the results read from it. Needs `ads:write`.
      tags: [Ads]
      responses:
        "200":
          description: Deleted.
          content: { application/json: { schema: { $ref: "#/components/schemas/Deleted" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/ads/summary:
    get:
      summary: Summarize ad results
      description: |
        Adds up the daily results of every campaign in the connected ad
        accounts (including campaigns made in the network's own tools)
        between `since` and `until`, inclusive (default: the last 30 days),
        grouped by brand, account, campaign or day: most spend first, or by
        day, oldest first. Amounts are integers in the minor unit of each
        group's currency; accounts can differ, so the currency is part of
        every group. Signups are not here: landing links carry UTM
        parameters for your own analytics. Needs `ads:read`.
      tags: [Ads]
      parameters:
        - { name: group_by, in: query, schema: { type: string, enum: [brand, account, campaign, day], default: campaign } }
        - { name: brand, in: query, schema: { type: string }, description: Brand ID or slug. }
        - { name: account, in: query, schema: { type: string }, description: An ad account ID. }
        - { name: since, in: query, schema: { type: string, format: date } }
        - { name: until, in: query, schema: { type: string, format: date } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
      responses:
        "200":
          description: The summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [ads_summary] }
                  group_by: { type: string, enum: [brand, account, campaign, day] }
                  since: { type: string, format: date }
                  until: { type: string, format: date }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: "The brand, ad account or campaign (the network's ID), or the day." }
                        label: { type: string, description: "The brand's or account's name, the campaign's name, or the day." }
                        network: { type: string, description: For accounts and campaigns. }
                        currency: { type: string, example: USD }
                        spend: { type: integer, description: In the currency's minor unit (cents). }
                        impressions: { type: integer }
                        clicks: { type: integer }
                        results: { type: integer, description: "The network's count of results for the campaign's objective." }
                        cost_per_click: { type: integer, description: "Spend divided by clicks, in the minor unit; 0 without clicks." }
                        visitors: { type: integer, description: "For campaigns: visitors the brand's web analytics credit to the campaign's tagged links (ADR 0025)." }
                        signups: { type: integer, description: "For campaigns: the conversions credited the same way." }
                        cost_per_signup: { type: integer, description: "Spend divided by signups, in the minor unit; 0 without signups." }
        default: { $ref: "#/components/responses/Error" }

  /v1/analytics_providers:
    get:
      summary: List web analytics providers
      description: |
        The web analytics tools this install can read (ADR 0025), with the
        fields needed to connect a site. Test mode has only `sandbox`,
        whose numbers are invented.
      tags: [Analytics]
      responses:
        "200":
          description: Providers.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/AnalyticsProvider" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/analytics_sources:
    get:
      summary: List analytics sources
      description: The brands' connected web analytics sites, in the key's mode. Needs `posts:read`.
      tags: [Analytics]
      parameters:
        - { name: brand, in: query, schema: { type: string }, description: Brand ID or slug. }
      responses:
        "200":
          description: Sources.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/AnalyticsSource" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Connect an analytics source
      description: |
        Checks the credentials with the provider and stores them encrypted.
        Araldo then reads, daily, the visits and goal completions the site
        recorded for each UTM tag: counts only, never visitors. Needs
        `brands:write`.
      tags: [Analytics]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [brand, provider]
              properties:
                brand: { type: string }
                provider: { type: string, example: plausible }
                goals:
                  type: array
                  maxItems: 20
                  items: { type: string }
                  description: The goals that count as conversions, as the tool names them (a Plausible goal, a GA4 key event).
                  example: [Waitlist Signup]
                fields:
                  type: object
                  additionalProperties: { type: string }
                  description: The provider's connect fields (see /v1/analytics_providers).
      responses:
        "201":
          description: The source.
          content: { application/json: { schema: { $ref: "#/components/schemas/AnalyticsSource" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/analytics_sources/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Get an analytics source
      tags: [Analytics]
      responses:
        "200":
          description: The source.
          content: { application/json: { schema: { $ref: "#/components/schemas/AnalyticsSource" } } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      summary: Disconnect an analytics source
      description: Forgets the source, its credentials and the counts read from it. Needs `brands:write`.
      tags: [Analytics]
      responses:
        "200":
          description: Deleted.
          content: { application/json: { schema: { $ref: "#/components/schemas/Deleted" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/analytics/summary:
    get:
      summary: Summarize visits and conversions
      description: |
        Adds up the visits and goal completions the brands' analytics
        recorded between `since` and `until`, inclusive (default: the last
        30 days), grouped by what Araldo's link tags name: `post` (the post
        each visit came from), `source` (the network), `medium`,
        `campaign` (the template, or an ad campaign), `content` or `day`.
        Visits whose tags Araldo did not write are in the summary's
        `untagged` count, so the share it explains is visible. The last
        days are provisional: tools revise them. Needs `posts:read`.
      tags: [Analytics]
      parameters:
        - { name: group_by, in: query, schema: { type: string, enum: [post, source, medium, campaign, content, day], default: post } }
        - { name: brand, in: query, schema: { type: string }, description: Brand ID or slug. }
        - { name: since, in: query, schema: { type: string, format: date } }
        - { name: until, in: query, schema: { type: string, format: date } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
      responses:
        "200":
          description: The summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [analytics_summary] }
                  group_by: { type: string }
                  since: { type: string, format: date }
                  until: { type: string, format: date }
                  visitors: { type: integer, description: All visitors in the window. }
                  untagged: { type: integer, description: Visitors who came without Araldo's tags. }
                  conversions: { type: integer, description: "Every conversion in the window, tagged or not." }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: "The post ID, or the tag's value, or the day." }
                        label: { type: string, description: "The post's first text, or the tag's value." }
                        visitors: { type: integer }
                        visits: { type: integer }
                        conversions: { type: integer, description: Visitors who completed one of the source's goals. }
                        goals:
                          type: object
                          additionalProperties: { type: integer }
                          description: Conversions by goal.
        default: { $ref: "#/components/responses/Error" }

  /v1/engagement/summary:
    get:
      summary: Summarize engagement
      description: |
        Adds up the latest engagement of targets published between `since`
        and `until` (default: the last 30 days), grouped by post, channel or
        template, most engaging first. Use it to see which announcements,
        networks and templates work. Clicks are not here: links carry UTM
        parameters for your web analytics.
      tags: [Engagement]
      parameters:
        - { name: group_by, in: query, schema: { type: string, enum: [post, channel, template], default: post } }
        - { name: brand, in: query, schema: { type: string }, description: Brand ID or slug. }
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: until, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
      responses:
        "200":
          description: The summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [engagement_summary] }
                  group_by: { type: string, enum: [post, channel, template] }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: "The post, channel or template; empty for posts without a template." }
                        label: { type: string, description: "The post's first text, the channel's name, or the template's key." }
                        provider: { type: string, description: For channels. }
                        posts: { type: integer }
                        targets: { type: integer }
                        likes: { type: integer }
                        reposts: { type: integer }
                        replies: { type: integer }
                        quotes: { type: integer }
                        views: { type: integer, description: Only where the platforms report views. }
                        total: { type: integer, description: "Likes, reposts, replies and quotes." }
        default: { $ref: "#/components/responses/Error" }

  /v1/mail_providers:
    get:
      summary: List email providers
      description: The providers mail accounts can connect to in the key's mode, with their connect fields.
      tags: [Newsletters]
      responses:
        "200":
          description: Providers.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/MailProvider" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/mail_accounts:
    get:
      summary: List mail accounts
      description: The brands' connected email provider accounts, in the key's mode. Needs `channels:read` or `newsletters:read`.
      tags: [Newsletters]
      parameters:
        - { name: brand, in: query, schema: { type: string }, description: Brand ID or slug. }
      responses:
        "200":
          description: Accounts.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/MailAccount" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Connect a mail account
      description: |
        Checks the key with the provider, and that the provider will send as
        `from_email`, and stores the key encrypted. The provider owns the
        subscribers, unsubscribes, bounces and complaints; Araldo never
        stores an address (ADR 0024). Needs `channels:write`.
      tags: [Newsletters]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [brand, provider, from_name, from_email]
              properties:
                brand: { type: string }
                provider: { type: string, example: brevo }
                from_name: { type: string, example: Araldo }
                from_email: { type: string, example: news@news.araldo.dev }
                reply_to: { type: string }
                default_audiences:
                  type: array
                  items: { type: string }
                  description: Audience IDs new issues go to (see the account's audiences).
                fields:
                  type: object
                  additionalProperties: { type: string }
                  description: The provider's connect fields (see /v1/mail_providers).
      responses:
        "201":
          description: The account.
          content: { application/json: { schema: { $ref: "#/components/schemas/MailAccount" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/mail_accounts/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Get a mail account
      tags: [Newsletters]
      responses:
        "200":
          description: The account.
          content: { application/json: { schema: { $ref: "#/components/schemas/MailAccount" } } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Update a mail account
      description: Changes the sender, checked with the provider when its address changes, and the default audiences. Needs `channels:write`.
      tags: [Newsletters]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from_name, from_email]
              properties:
                from_name: { type: string }
                from_email: { type: string }
                reply_to: { type: string }
                default_audiences: { type: array, items: { type: string } }
      responses:
        "200":
          description: The account.
          content: { application/json: { schema: { $ref: "#/components/schemas/MailAccount" } } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      summary: Disconnect a mail account
      description: |
        Forgets the account and its key. Refused while scheduled issues go
        through it; past issues keep their results. Needs `channels:write`.
      tags: [Newsletters]
      responses:
        "200":
          description: Deleted.
          content: { application/json: { schema: { $ref: "#/components/schemas/Deleted" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/mail_accounts/{id}/audiences:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: List an account's audiences
      description: What the provider groups subscribers by (Brevo's lists and segments), read from it now. Never their members.
      tags: [Newsletters]
      responses:
        "200":
          description: Audiences.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Audience" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/newsletters:
    get:
      summary: List newsletter issues
      description: Issues in the key's mode, newest first. Needs `newsletters:read`.
      tags: [Newsletters]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - { name: brand, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/IssueStatus" } }
      responses:
        "200":
          description: Issues.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Issue" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Create a newsletter issue
      description: |
        Stores a draft. Its body is Markdown: headings, paragraphs, lists,
        quotes, `---` dividers, `![alt](media_… or https://…)` images and
        `[Label](https://…){.button}` buttons on lines of their own, and
        bold, italic, code and links. Araldo renders it into email-safe
        HTML and plain text. Without `deliveries`, it goes to each of the
        brand's active mail accounts with default audiences. Needs
        `newsletters:write`.
      tags: [Newsletters]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [brand]
                  properties:
                    brand: { type: string }
                - $ref: "#/components/schemas/IssueInput"
      responses:
        "201":
          description: The issue.
          content: { application/json: { schema: { $ref: "#/components/schemas/Issue" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/newsletters/preview:
    post:
      summary: Preview a newsletter issue
      description: Renders an issue as its first delivery's provider would send it, links tagged, without storing anything.
      tags: [Newsletters]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [brand]
                  properties:
                    brand: { type: string }
                - $ref: "#/components/schemas/IssueInput"
      responses:
        "200":
          description: The rendering.
          content: { application/json: { schema: { $ref: "#/components/schemas/IssuePreview" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/newsletters/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Get a newsletter issue
      tags: [Newsletters]
      responses:
        "200":
          description: The issue, with each delivery's status and results.
          content: { application/json: { schema: { $ref: "#/components/schemas/Issue" } } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Edit a draft
      description: Replaces a draft's subject, preview text and body; `deliveries`, when given, replace its accounts and audiences.
      tags: [Newsletters]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IssueInput" }
      responses:
        "200":
          description: The issue.
          content: { application/json: { schema: { $ref: "#/components/schemas/Issue" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/newsletters/{id}/preview:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Render a newsletter issue
      tags: [Newsletters]
      responses:
        "200":
          description: The rendering.
          content: { application/json: { schema: { $ref: "#/components/schemas/IssuePreview" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/newsletters/{id}/schedule:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Schedule or move a newsletter issue
      description: |
        Schedules a draft, which waits for approval under the brand's
        policy, or moves a scheduled issue, at its providers too once it
        has been handed to them. A day before `send_at` each delivery is
        handed to its provider, which sends it without Araldo. Needs
        `newsletters:write`.
      tags: [Newsletters]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [send_at]
              properties:
                send_at: { type: string, format: date-time }
      responses:
        "200":
          description: The issue.
          content: { application/json: { schema: { $ref: "#/components/schemas/Issue" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/newsletters/{id}/unschedule:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Return an issue to draft
      description: Removes its campaigns from the providers, so it can be edited; it needs approval again. Needs `newsletters:write`.
      tags: [Newsletters]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses:
        "200":
          description: The issue.
          content: { application/json: { schema: { $ref: "#/components/schemas/Issue" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/newsletters/{id}/cancel:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Cancel a newsletter issue
      description: Stops every delivery not yet sent, at the providers too. Needs `newsletters:write`.
      tags: [Newsletters]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses:
        "200":
          description: The issue.
          content: { application/json: { schema: { $ref: "#/components/schemas/Issue" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/newsletters/{id}/approve:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Approve a newsletter issue
      description: Needs `posts:approve`, which a key holds only when it lists it. A key cannot approve an issue it created.
      tags: [Newsletters]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                note: { type: string }
      responses:
        "200":
          description: The issue.
          content: { application/json: { schema: { $ref: "#/components/schemas/Issue" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/newsletters/{id}/reject:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Reject a newsletter issue
      description: Returns it to draft with the note. Needs `posts:approve`.
      tags: [Newsletters]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                note: { type: string }
      responses:
        "200":
          description: The issue.
          content: { application/json: { schema: { $ref: "#/components/schemas/Issue" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/newsletters/{id}/test:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Send a test
      description: |
        Sends the issue, its subject marked as a test, through one of the
        brand's mail accounts to up to five addresses, which are not
        stored. Needs `newsletters:write`.
      tags: [Newsletters]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mail_account, to]
              properties:
                mail_account: { type: string }
                to: { type: array, minItems: 1, maxItems: 5, items: { type: string } }
      responses:
        "200":
          description: Sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [newsletter_test] }
                  sent: { type: integer }
        default: { $ref: "#/components/responses/Error" }

  /v1/reports:
    get:
      summary: Get a brand's report
      description: |
        A brand's results over a period, beside the period of the same
        length just before it: publishing, engagement, web traffic, ads and
        newsletters, computed now from what Araldo has read (ADR 0026). A
        section is null when the brand has nothing in it, or the key may
        not see it (ads need `ads:read`, newsletters `newsletters:read`).
        Without `month`, `since` or `until`, the last full month in the
        brand's time zone. Needs `posts:read`.
      tags: [Reports]
      parameters:
        - { name: brand, in: query, required: true, schema: { type: string }, description: Brand ID or slug. }
        - { name: month, in: query, schema: { type: string, example: "2026-09" }, description: "A calendar month, YYYY-MM." }
        - { name: since, in: query, schema: { type: string, format: date } }
        - { name: until, in: query, schema: { type: string, format: date } }
      responses:
        "200":
          description: The report.
          content: { application/json: { schema: { $ref: "#/components/schemas/Report" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/events:
    get:
      summary: List events
      tags: [Events]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - { name: type, in: query, schema: { $ref: "#/components/schemas/EventType" } }
      responses:
        "200":
          description: Events, newest first (kept 30 days).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Event" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/events/stream:
    get:
      summary: Stream events as they happen
      description: |
        The org's new events, in the credential's mode, as server-sent events
        (`text/event-stream`): each has an `id` (the event's), an `event` (its
        type) and `data` (the event, as `GET /v1/events/{id}` returns it). A
        comment every 15 seconds keeps the connection open. Reconnect with
        `Last-Event-ID` (or `starting_after`) to resume after the last event
        seen; otherwise the stream starts now. `araldo listen` uses it to
        forward events to a local URL, like `stripe listen`. Needs `events:read`.
      tags: [Events]
      parameters:
        - name: types
          in: query
          description: Only these event types, comma-separated.
          schema: { type: string, example: "post.published,post.failed" }
        - $ref: "#/components/parameters/StartingAfter"
        - name: Last-Event-ID
          in: header
          description: The last event seen, to resume after it.
          schema: { type: string }
      responses:
        "200":
          description: The stream.
          content:
            text/event-stream:
              schema: { type: string }
        default: { $ref: "#/components/responses/Error" }

  /v1/events/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Retrieve an event
      tags: [Events]
      responses:
        "200":
          description: The event.
          content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/me:
    get:
      summary: Who the credential is
      description: |
        The org and mode the calling credential acts in, and the API key it
        is. Any credential may read itself, whatever its scopes. Clients
        such as the `araldo` CLI use it to show who they are signed in as.
      tags: [API keys]
      responses:
        "200":
          description: The credential.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        default: { $ref: "#/components/responses/Error" }

  /v1/auth/device:
    post:
      summary: Start a device sign-in
      description: |
        The first step of the OAuth 2.0 device authorization grant (RFC 8628)
        the `araldo` CLI signs in with (ADR 0028). Show the person the
        `user_code` and `verification_uri`; they approve it in the dashboard,
        signed in, while you poll `POST /v1/auth/device/token` every
        `interval` seconds. Needs no credential.
      security: []
      tags: [Sign-in]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                device_name: { type: string, maxLength: 100, description: "Shown to the person approving, for example araldo CLI on laptop." }
                livemode: { type: boolean, description: A live token (default a test one). }
      responses:
        "200":
          description: The codes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  device_code: { type: string, description: Secret; poll with it. }
                  user_code: { type: string, example: BDFG-HJKL }
                  verification_uri: { type: string, description: "Where the person types the code. There is no verification_uri_complete: the code is always typed (RFC 8628 §5.4)." }
                  expires_in: { type: integer, description: Seconds. }
                  interval: { type: integer, description: Seconds between polls. }
        default: { $ref: "#/components/responses/Error" }

  /v1/auth/device/token:
    post:
      summary: Poll a device sign-in for its token
      description: |
        Until the person decides, a 400 problem whose `code` is
        `authorization_pending`, or `slow_down` (wait five seconds longer from
        now on); then `access_denied`, `expired_token`, or the token, once.
        Needs no credential.
      security: []
      tags: [Sign-in]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_code]
              properties:
                device_code: { type: string }
      responses:
        "200":
          description: The token, shown only this once.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [user_token] }
                  access_token: { type: string, example: ald_user_… }
                  token_type: { type: string, enum: [bearer] }
                  user_token: { $ref: "#/components/schemas/UserToken" }
        default: { $ref: "#/components/responses/Error" }

  /v1/auth/token:
    delete:
      summary: Revoke the calling user token
      description: Signs this device out (`araldo auth logout`). Only a user token can; revoke API keys in the dashboard.
      tags: [Sign-in]
      responses:
        "200":
          description: The token is revoked.
          content: { application/json: { schema: { $ref: "#/components/schemas/Deleted" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/org:
    get:
      summary: The org
      description: The org the request acts in. A user token only, never an API key.
      tags: [Members]
      responses:
        "200":
          description: The org.
          content: { application/json: { schema: { $ref: "#/components/schemas/Org" } } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Update the org
      description: |
        Owners only. No longer requiring two-factor authentication needs a
        recent password confirmation, so it is done in the dashboard.
      tags: [Members]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 100 }
                require_mfa: { type: boolean }
      responses:
        "200":
          description: The org.
          content: { application/json: { schema: { $ref: "#/components/schemas/Org" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/members:
    get:
      summary: List members
      description: Everyone in the org, with their roles. A user token only.
      tags: [Members]
      responses:
        "200":
          description: Members.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Member" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/members/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Get a member
      description: By the person's user ID. A user token only.
      tags: [Members]
      responses:
        "200":
          description: The member.
          content: { application/json: { schema: { $ref: "#/components/schemas/Member" } } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Change a member's role
      description: |
        Admins and owners. Making or unmaking an owner needs a recent password
        confirmation, so it is done in the dashboard. An org keeps an owner.
      tags: [Members]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { type: string, enum: [owner, admin, editor, viewer] }
      responses:
        "200":
          description: The member.
          content: { application/json: { schema: { $ref: "#/components/schemas/Member" } } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      summary: Remove a member
      description: Admins and owners; removing an owner is done in the dashboard.
      tags: [Members]
      responses:
        "200":
          description: Removed.
          content: { application/json: { schema: { $ref: "#/components/schemas/Deleted" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/invitations:
    get:
      summary: List open invitations
      description: Admins and owners. A user token only.
      tags: [Members]
      responses:
        "200":
          description: Invitations not yet accepted.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Invitation" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Invite someone
      description: |
        Makes a one-time link, valid for a week, for the person to join the
        org with the role: they open it, then sign in or create their
        account. The link is in this response only. Inviting says nothing
        about whether the email has an account. Inviting an owner is done in
        the dashboard.
      tags: [Members]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [owner, admin, editor, viewer] }
                send_email: { type: boolean, default: false, description: "Email the link to the person too, from the server's own email; `emailed` in the response says whether it went." }
      responses:
        "201":
          description: The invitation, with its link.
          content: { application/json: { schema: { $ref: "#/components/schemas/Invitation" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/invitations/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    delete:
      summary: Withdraw an invitation
      tags: [Members]
      responses:
        "200":
          description: Withdrawn; its link no longer works.
          content: { application/json: { schema: { $ref: "#/components/schemas/Deleted" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/notifications:
    get:
      summary: List your notifications
      description: |
        What Araldo told the person the user token acts for, in every org of
        theirs and about their account, newest first: the same as the
        dashboard's inbox. A user token only.
      tags: [Notifications]
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/StartingAfter" }
        - { name: unread, in: query, schema: { type: boolean }, description: "Only unread ones." }
      responses:
        "200":
          description: Notifications.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Notification" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/notifications/read_all:
    post:
      summary: Mark all your notifications read
      tags: [Notifications]
      responses:
        "200":
          description: Done.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [notifications.read_all] }
                  unread: { type: integer }
        default: { $ref: "#/components/responses/Error" }

  /v1/notifications/{id}/read:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Mark a notification read
      tags: [Notifications]
      responses:
        "200":
          description: The notification.
          content: { application/json: { schema: { $ref: "#/components/schemas/Notification" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/operator/orgs:
    get:
      summary: List orgs
      description: Every org on the install, newest first; or, with `external_ref`, the one org with it (an empty list if none).
      tags: [Operator]
      security: [{ operatorKey: [] }]
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/StartingAfter" }
        - { $ref: "#/components/parameters/EndingBefore" }
        - { name: external_ref, in: query, schema: { type: string } }
      responses:
        "200":
          description: Orgs.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/OperatedOrg" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Create an org
      description: |
        Makes an org with no members and invites its first owner: send them
        `owner_invitation.url`, valid for a week, where they create their
        account (or sign in) and join. An external reference, unique on the
        install, ties the org to the caller's own records; a second org with
        the same one is refused (`external_ref_taken`), so a retry is safe.
      tags: [Operator]
      security: [{ operatorKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, owner_email]
              properties:
                name: { type: string, maxLength: 100 }
                owner_email: { type: string, format: email }
                external_ref: { type: string, maxLength: 200 }
                limits: { $ref: "#/components/schemas/OrgLimits" }
                send_email: { type: boolean, default: false, description: "Email the owner's invitation to them too; `owner_invitation.emailed` says whether it went." }
      responses:
        "201":
          description: The org, with its owner's invitation.
          content: { application/json: { schema: { $ref: "#/components/schemas/OperatedOrg" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/operator/orgs/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Get an org
      tags: [Operator]
      security: [{ operatorKey: [] }]
      responses:
        "200":
          description: The org.
          content: { application/json: { schema: { $ref: "#/components/schemas/OperatedOrg" } } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Change an org
      description: |
        Changes what is given and leaves the rest. `limits` replaces all
        four: a limit left out or null is none. Lowering a limit removes
        nothing; it stops more. `read_only` lets the org read but change
        nothing (scheduled posts still go out); `suspended` refuses its keys
        and tokens, shows its members only a notice, and publishes and sends
        nothing. The note is shown to the org.
      tags: [Operator]
      security: [{ operatorKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 100 }
                status: { type: string, enum: [active, read_only, suspended] }
                status_note: { type: string, maxLength: 500 }
                external_ref: { type: string, maxLength: 200 }
                limits: { $ref: "#/components/schemas/OrgLimits" }
      responses:
        "200":
          description: The org.
          content: { application/json: { schema: { $ref: "#/components/schemas/OperatedOrg" } } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      summary: Delete an org
      description: Deletes the org and everything in it, for good; its encrypted data cannot be recovered. `confirm` is the org's exact name.
      tags: [Operator]
      security: [{ operatorKey: [] }]
      parameters:
        - { name: confirm, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: Deleted.
          content: { application/json: { schema: { $ref: "#/components/schemas/Deleted" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/operator/orgs/{id}/invitations:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Invite someone to an org
      description: With any role, owners included, such as a new link for a first owner whose invitation expired.
      tags: [Operator]
      security: [{ operatorKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [owner, admin, editor, viewer] }
                send_email: { type: boolean, default: false, description: "Email the link to the person too, from the server's own email; `emailed` in the response says whether it went." }
      responses:
        "201":
          description: The invitation, with its link.
          content: { application/json: { schema: { $ref: "#/components/schemas/Invitation" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/operator/orgs/{id}/usage:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: An org's usage
      description: What the org has now, and what it did in a calendar month (UTC), this one unless `month` is given.
      tags: [Operator]
      security: [{ operatorKey: [] }]
      parameters:
        - { name: month, in: query, schema: { type: string, pattern: "^[0-9]{4}-[0-9]{2}$" }, description: "Such as 2026-10." }
      responses:
        "200":
          description: Usage.
          content: { application/json: { schema: { $ref: "#/components/schemas/Usage" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/api_keys:
    get:
      summary: List API keys
      description: Keys in the calling key's mode (and brand, if it is limited to one). Needs the explicit `keys:write` scope.
      tags: [API keys]
      responses:
        "200":
          description: Keys, without their secrets.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/APIKey" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Create an API key
      description: |
        Needs the explicit `keys:write` scope. The new key is in the calling
        key's mode, limited to its brand if it is limited to one, and holds
        only scopes the calling key holds; it can never hold an
        administrative scope. The secret is returned once.
      tags: [API keys]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 100 }
                scopes: { type: array, items: { type: string }, description: Empty means every scope the caller can grant. }
                brand: { type: string, description: Brand ID or slug to limit the key to. }
                expires_at:
                  type: string
                  format: date-time
                  description: Required, and no later than the calling key's own expiry, when the calling key expires.
      responses:
        "201":
          description: The key, with its secret.
          content: { application/json: { schema: { $ref: "#/components/schemas/APIKey" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/api_keys/self/roll:
    post:
      summary: Roll the calling key
      description: |
        Replaces the calling key's secret with a new key holding the same
        scopes; the old secret keeps working for `overlap_hours` (default
        24), so rotation needs no downtime. Any key may roll itself. The new
        key expires when the old one would have: rolling never extends a
        key's life (a member can, in the dashboard).
      tags: [API keys]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RollInput" }
      responses:
        "200":
          description: The new key, with its secret.
          content: { application/json: { schema: { $ref: "#/components/schemas/APIKey" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/api_keys/{id}/roll:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Roll an API key
      description: Like rolling the calling key, for a key this one could create. Needs the explicit `keys:write` scope.
      tags: [API keys]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RollInput" }
      responses:
        "200":
          description: The new key, with its secret.
          content: { application/json: { schema: { $ref: "#/components/schemas/APIKey" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/api_keys/{id}/revoke:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Revoke an API key
      description: Stops the key working at once. Needs the explicit `keys:write` scope.
      tags: [API keys]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses:
        "200":
          description: The revoked key (or, when a key revoked itself, a deletion notice).
          content: { application/json: { schema: { $ref: "#/components/schemas/APIKey" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/audit_events:
    get:
      summary: List the audit log
      description: Who (a member or an API key) did what, newest first. Needs the explicit `audit:read` scope.
      tags: [Audit]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
      responses:
        "200":
          description: Audit entries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/AuditEvent" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/webhook_endpoints:
    get:
      summary: List webhook endpoints
      tags: [Webhooks]
      responses:
        "200":
          description: Endpoints in the key's mode.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/WebhookEndpoint" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Create a webhook endpoint
      description: The response includes the signing `secret`, shown only now.
      tags: [Webhooks]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointInput" }
      responses:
        "201":
          description: The endpoint, with its secret.
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/webhook_endpoints/{id}:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: Retrieve a webhook endpoint
      tags: [Webhooks]
      responses:
        "200":
          description: The endpoint.
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Update a webhook endpoint
      tags: [Webhooks]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEndpointInput"
                - type: object
                  properties:
                    disabled: { type: boolean }
      responses:
        "200":
          description: The endpoint.
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      summary: Delete a webhook endpoint
      tags: [Webhooks]
      responses:
        "200":
          description: Deleted.
          content: { application/json: { schema: { $ref: "#/components/schemas/Deleted" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/webhook_endpoints/{id}/roll_secret:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Replace an endpoint's signing secret
      description: |
        Makes a new signing secret. For `overlap_hours` (default 24) each
        delivery's `Araldo-Signature` carries a `v1` signature with the old
        secret as well as the new one, so the receiver can switch without
        rejecting a delivery.
      tags: [Webhooks]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RollInput" }
      responses:
        "200":
          description: The endpoint, with its new secret.
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } }
        default: { $ref: "#/components/responses/Error" }

  /v1/webhook_endpoints/{id}/deliveries:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    get:
      summary: List an endpoint's deliveries
      tags: [Webhooks]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
      responses:
        "200":
          description: Deliveries, newest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListBase"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/WebhookDelivery" } }
        default: { $ref: "#/components/responses/Error" }

  /v1/webhook_deliveries/{id}/resend:
    parameters: [{ $ref: "#/components/parameters/ID" }]
    post:
      summary: Resend a delivery
      tags: [Webhooks]
      responses:
        "202":
          description: Queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  object: { type: string }
                  status: { type: string }
        default: { $ref: "#/components/responses/Error" }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        An API key (ald_test_… or ald_live_…), or a person's user token from
        the CLI's device sign-in (ald_user_…, ADR 0028). A user token acts
        as the person in the org the `Araldo-Org` header names (an ID or
        name; not needed for someone in one org), with their current role,
        in the token's mode. Idempotency-Key applies to API keys.
    operatorKey:
      type: http
      scheme: bearer
      description: An operator key (ald_op_…), for the operator API only (ADR 0031).

  parameters:
    ID:
      name: id
      in: path
      required: true
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema: { type: string, maxLength: 255 }
      description: Makes the request safe to retry for 24 hours.
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    StartingAfter:
      name: starting_after
      in: query
      schema: { type: string }
      description: An object ID; returns older objects.
    EndingBefore:
      name: ending_before
      in: query
      schema: { type: string }
      description: An object ID; returns newer objects.

  responses:
    Error:
      description: A problem.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:
    Problem:
      type: object
      required: [type, title, status, code]
      properties:
        type: { type: string }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        code: { type: string, example: too_long }
        param: { type: string }
        request_id: { type: string }
        doc_url: { type: string }
        errors:
          type: array
          items:
            type: object
            properties:
              code: { type: string }
              param: { type: string }
              message: { type: string }
              detail: { type: object, additionalProperties: true }

    ListBase:
      type: object
      required: [object, data, has_more, url]
      properties:
        object: { type: string, enum: [list] }
        data: { type: array, items: {} }
        has_more: { type: boolean }
        url: { type: string }

    Deleted:
      type: object
      properties:
        id: { type: string }
        object: { type: string }
        deleted: { type: boolean }

    AnalyticsProvider:
      type: object
      properties:
        provider: { type: string, example: plausible }
        name: { type: string }
        connect_fields:
          type: array
          items:
            type: object
            properties:
              Name: { type: string }
              Label: { type: string }
              Help: { type: string }
              Secret: { type: boolean }
              Optional: { type: boolean }
              Default: { type: string }

    AnalyticsSource:
      type: object
      properties:
        id: { type: string, example: anlsrc_01j9x3k2v7e8f9g0h1j2k3m4n5 }
        object: { type: string, enum: [analytics_source] }
        brand: { type: string }
        livemode: { type: boolean }
        provider: { type: string }
        site: { type: string, description: "The site or property, as the provider names it." }
        name: { type: string }
        timezone: { type: string }
        goals: { type: array, items: { type: string } }
        status: { type: string, enum: [active, needs_reauth] }
        status_note: { type: string }
        read_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    AdNetwork:
      type: object
      properties:
        network: { type: string, example: sandbox }
        name: { type: string }
        reporting: { type: boolean, description: Araldo reads results from connected accounts. }
        promotions: { type: boolean, description: Araldo can run promotions here (a later phase of ADR 0023). }
        connect_fields:
          type: array
          items:
            type: object
            properties:
              Name: { type: string }
              Label: { type: string }
              Help: { type: string }
              Secret: { type: boolean }
              Optional: { type: boolean }
              Default: { type: string }

    AdAccount:
      type: object
      properties:
        id: { type: string, example: adacct_01j9x3k2v7e8f9g0h1j2k3m4n5 }
        object: { type: string, enum: [ad_account] }
        brand: { type: string }
        livemode: { type: boolean }
        network: { type: string }
        external_id: { type: string, description: The network's ID for the account. }
        name: { type: string }
        currency: { type: string, example: USD }
        timezone: { type: string, example: America/Denver }
        status: { type: string, enum: [active, needs_reauth] }
        status_note: { type: string }
        read_at: { type: string, format: date-time, description: When results were last read. }
        created_at: { type: string, format: date-time }

    Platform:
      type: object
      properties:
        provider: { type: string }
        name: { type: string }
        max_length: { type: integer }
        counting: { type: string, enum: [characters, graphemes, x_weighted, mastodon, bluesky] }
        threads: { type: boolean }
        max_thread_parts: { type: integer }
        media_required: { type: boolean }
        max_media: { type: integer }
        source: { type: string }
        images:
          type: object
          properties:
            max_bytes:
              type: object
              additionalProperties: { type: integer }
              description: Each image type the platform takes, with its size limit in bytes (0 when none is documented).
              example: { image/jpeg: 1000000, image/png: 1000000 }
            min_aspect_ratio: { type: number, description: "Width divided by height, at least." }
            max_aspect_ratio: { type: number, description: "Width divided by height, at most." }
            max_width_plus_height: { type: integer }
            max_length_with_media: { type: integer, description: "The text limit when the post has media (it becomes a caption)." }
            source: { type: string }
        connect_fields:
          type: array
          items:
            type: object
            properties:
              Name: { type: string }
              Label: { type: string }
              Help: { type: string }
              Secret: { type: boolean }
              Optional: { type: boolean }
              Default: { type: string }

    BrandInput:
      type: object
      properties:
        name: { type: string }
        slug: { type: string }
        timezone: { type: string, example: America/Denver }
        approval_policy: { type: string, enum: [none, required_for_editors_and_keys, required_for_all] }
        utm_domains:
          type: array
          maxItems: 20
          items: { type: string, example: araldo.dev }
          description: |
            Sites whose links get UTM parameters, so web analytics can tell
            which post and network a visit came from. A link to one of these
            domains or a subdomain gets utm_source (the network), utm_medium
            (social), utm_campaign (the template's key) and utm_content (the
            post's ID), unless it already has a utm_ parameter. Empty: no
            tagging.
        slots:
          type: array
          maxItems: 200
          items: { $ref: "#/components/schemas/Slot" }
          description: |
            Weekly publishing times, used by `publish_at: "next_slot"`.
            Replaces them all when present. A new brand without slots gets
            weekdays at 09:00 and 13:00.

    Brand:
      type: object
      properties:
        id: { type: string, example: brand_01j9x3k2v7e8f9g0h1j2k3m4n5 }
        object: { type: string, enum: [brand] }
        name: { type: string }
        slug: { type: string }
        timezone: { type: string }
        approval_policy: { type: string }
        utm_domains: { type: array, items: { type: string } }
        slots: { type: array, items: { $ref: "#/components/schemas/Slot" } }
        email_theme: { $ref: "#/components/schemas/EmailTheme" }
        created_at: { type: string, format: date-time }

    Slot:
      type: object
      description: A weekly publishing time in the brand's time zone.
      required: [weekday, time]
      properties:
        weekday: { type: string, enum: [sunday, monday, tuesday, wednesday, thursday, friday, saturday] }
        time: { type: string, example: "09:00", description: 24-hour HH:MM. }

    Channel:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [channel] }
        brand: { type: string }
        livemode: { type: boolean }
        provider: { type: string }
        emulates: { type: string }
        display_name: { type: string }
        handle: { type: string }
        profile_url: { type: string }
        settings:
          type: object
          additionalProperties: { type: string }
          description: The non-secret connect fields. Secrets are write-only and never returned.
        status: { type: string, enum: [active, needs_reauth, disabled] }
        status_note: { type: string }
        checked_at: { type: string, format: date-time, description: "The last daily check of the channel's credentials with its platform (live channels)." }
        check_error: { type: string, description: What the last check found wrong; absent when it passed. }
        created_at: { type: string, format: date-time }

    TemplateSource:
      type: object
      properties:
        variables:
          type: object
          additionalProperties: true
          description: A JSON Schema (2020-12) for the data.
        examples: { type: array, items: { type: object, additionalProperties: true } }
        body: { type: string, example: "Featured build: {{.name}} {{.url}}" }
        overrides:
          type: object
          additionalProperties: { type: string }
          description: Per-platform bodies.
        fit:
          type: object
          additionalProperties: { type: string, enum: [error, truncate, thread] }

    TemplateApproval:
      type: string
      enum: [inherit, required, not_required]
      description: |
        Whether posts made from the template wait for approval: `inherit`
        follows the brand's policy (the default), `required` always waits,
        `not_required` never does.

    Template:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [template] }
        brand: { type: string }
        key: { type: string }
        name: { type: string }
        approval: { $ref: "#/components/schemas/TemplateApproval" }
        latest_version: { type: integer }
        latest:
          allOf:
            - $ref: "#/components/schemas/TemplateSource"
            - type: object
              properties:
                version: { type: integer }
                created_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Content:
      type: object
      properties:
        body: { type: string }
        parts: { type: array, items: { type: string }, description: Explicit thread parts. }
        overrides: { type: object, additionalProperties: { type: string } }
        fit: { type: object, additionalProperties: { type: string, enum: [error, truncate, thread] } }

    PostInput:
      type: object
      required: [brand]
      properties:
        brand: { type: string, description: Brand ID or slug. }
        template: { type: string, description: "key, key@version or template ID" }
        data: { type: object, additionalProperties: true }
        content: { $ref: "#/components/schemas/Content" }
        channels:
          type: array
          items: { type: string }
          description: Default every active channel of the brand in this mode.
        publish_at:
          type: string
          description: |
            "now" (default), "next_slot", or an RFC 3339 time. A
            `next_slot` post that needs approval takes its slot when it is
            approved, not when it is created.
        publish_by:
          type: string
          format: date-time
          description: |
            Give up after this (default publish_at + 24h). For a
            `next_slot` post waiting for approval, its slot must come
            before this.
        metadata:
          type: object
          additionalProperties: { type: string }
          description: |
            Up to 50 keys of your own. In test mode, `araldo_simulate`
            imitates a failure: rate_limited, auth_revoked, rejected,
            timeout_after_send or slow.
        media:
          type: array
          maxItems: 10
          items: { type: string, example: media_01j9x3k2v7e8f9g0h1j2k3m4n5 }
          description: |
            Images to attach, in order, from `/v1/media`, of the post's brand
            and mode. They go on the first part of a thread. With media, the
            text may be empty.

    RescheduleInput:
      type: object
      description: Give `publish_at` or `swap_with`.
      properties:
        publish_at:
          type: string
          description: '"now", "next_slot", or an RFC 3339 time.'
        publish_by:
          type: string
          format: date-time
          description: Give up after this (default publish_at + 24h).
        swap_with:
          type: string
          description: Another post of the same brand and mode to trade places with.
          example: post_01j9x3k2v7e8f9g0h1j2k3m4n5

    Media:
      type: object
      properties:
        id: { type: string, example: media_01j9x3k2v7e8f9g0h1j2k3m4n5 }
        object: { type: string, enum: [media] }
        brand: { type: string }
        livemode: { type: boolean }
        type: { type: string, enum: [image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime] }
        size: { type: integer, description: Bytes. }
        width: { type: integer, description: "As shown: a video recorded upright on a phone is taller than wide." }
        height: { type: integer }
        alt: { type: string }
        filename: { type: string }
        sha256: { type: string }
        video:
          type: object
          description: For a video, what its index says (ADR 0027). Araldo does not transcode.
          properties:
            duration_ms: { type: integer }
            frame_rate: { type: number }
            video_codec: { type: string, example: avc1, description: "The video's sample entry: avc1 is H.264, hvc1 HEVC." }
            audio_codec: { type: string, example: mp4a, description: "mp4a is AAC; absent for a silent video." }
        created_at: { type: string, format: date-time }

    PostStatus:
      type: string
      enum: [pending_approval, scheduled, publishing, published, partially_published, failed, canceled, rejected]

    Post:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [post] }
        brand: { type: string }
        livemode: { type: boolean }
        status: { $ref: "#/components/schemas/PostStatus" }
        template: { type: string }
        template_version: { type: integer }
        data: { type: object, additionalProperties: true }
        content: { $ref: "#/components/schemas/Content" }
        publish_at:
          type: string
          format: date-time
          nullable: true
          description: |
            When it publishes. Null while a `next_slot` post waits for
            approval: it takes the brand's next free slot when approved.
        publish_by:
          type: string
          format: date-time
          nullable: true
          description: When it gives up. Null with `publish_at`, unless it was given.
        slot:
          type: boolean
          description: Holds one of the brand's slots, or takes one when approved.
        metadata: { type: object, additionalProperties: { type: string } }
        approval:
          type: object
          properties:
            required: { type: boolean }
            reviewed_by: { type: string, description: The member who reviewed it. }
            reviewed_by_key: { type: string, description: The API key that reviewed it. }
            reviewed_at: { type: string, format: date-time }
            note: { type: string }
        media: { type: array, items: { $ref: "#/components/schemas/Media" } }
        targets: { type: array, items: { $ref: "#/components/schemas/PostTarget" } }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PostTarget:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [post_target] }
        post: { type: string }
        channel: { type: string }
        channel_name: { type: string }
        provider: { type: string }
        parts: { type: array, items: { type: string } }
        status: { type: string, enum: [held, queued, publishing, published, failed, needs_attention, canceled] }
        attempts: { type: integer }
        next_attempt_at: { type: string, format: date-time }
        publish_by: { type: string, format: date-time, nullable: true }
        permalink: { type: string }
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
        published_at: { type: string, format: date-time }
        engagement: { $ref: "#/components/schemas/Engagement" }
        livemode: { type: boolean }

    Engagement:
      type: object
      description: |
        A published target's latest engagement reading (ADR 0018). A thread
        counts as one post: its parts are added up, less its own replies.
      properties:
        likes: { type: integer }
        reposts: { type: integer }
        replies: { type: integer }
        quotes: { type: integer }
        views: { type: integer, description: Only where the platform reports views. }
        total: { type: integer, description: "Likes, reposts, replies and quotes." }
        state:
          type: string
          enum: [collecting, done, deleted, unsupported]
          description: "done: the schedule is over; deleted: the post is gone from the platform; unsupported: the platform reports no engagement."
        read_at: { type: string, format: date-time, description: Absent until the first reading. }
        next_read_at: { type: string, format: date-time }

    ReviewInput:
      type: object
      properties:
        note: { type: string, maxLength: 500 }

    RollInput:
      type: object
      properties:
        overlap_hours: { type: integer, minimum: 0, maximum: 168, default: 24, description: How long the old secret keeps working. }

    APIKey:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [api_key] }
        name: { type: string }
        hint: { type: string, description: The key's last characters. }
        livemode: { type: boolean }
        scopes: { type: array, items: { type: string }, description: Empty means every integration scope. }
        brand: { type: string }
        secret: { type: string, description: The full key; only when it is created or rolled. }
        created_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        revoked_at: { type: string, format: date-time }

    Me:
      type: object
      required: [object, livemode, org]
      properties:
        object: { type: string, enum: [me] }
        livemode: { type: boolean }
        org:
          type: object
          required: [id, name]
          properties:
            id: { type: string }
            name: { type: string }
        api_key: { $ref: "#/components/schemas/APIKey" }
        user:
          type: object
          description: The person, for a user token.
          properties:
            id: { type: string }
            email: { type: string }
            name: { type: string }
        user_token: { $ref: "#/components/schemas/UserToken" }
        role: { type: string, enum: [owner, admin, editor, viewer], description: "The person's role in the org, for a user token." }

    Org:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [org] }
        name: { type: string }
        require_mfa: { type: boolean }
        require_sso:
          type: boolean
          description: Members reach the org only signed in through its single sign-on; owners set it in the dashboard.
        status:
          type: string
          enum: [active, read_only, suspended]
          description: Set by the install's operator. A read-only org can read but not change anything; a suspended one is refused.
        status_note: { type: string, description: The operator's note on the status. }
        limits: { $ref: "#/components/schemas/OrgLimits" }
        created_at: { type: string, format: date-time }

    OrgLimits:
      type: object
      description: What the org may have, set by the install's operator; null is no limit.
      properties:
        brands: { type: integer, nullable: true, minimum: 0 }
        channels: { type: integer, nullable: true, minimum: 0, description: Live channels. }
        members: { type: integer, nullable: true, minimum: 0, description: Members and open invitations. }
        posts_per_month: { type: integer, nullable: true, minimum: 0, description: Live posts created in a calendar month (UTC). }

    OperatedOrg:
      allOf:
        - $ref: "#/components/schemas/Org"
        - type: object
          properties:
            external_ref: { type: string, description: The operator's reference for the org; empty if none. }
            owner_invitation:
              allOf: [{ $ref: "#/components/schemas/Invitation" }]
              description: The first owner's invitation, with its link; only in the response that created the org.

    Usage:
      type: object
      properties:
        object: { type: string, enum: [usage] }
        org: { type: string }
        period_start: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
        brands: { type: integer }
        live_channels: { type: integer }
        members: { type: integer, description: Members and open invitations. }
        live_posts_created: { type: integer, description: In the period. }
        live_targets_published: { type: integer, description: "Posts published to a channel, in the period." }
        media_bytes: { type: integer, format: int64, description: Media stored now. }

    Member:
      type: object
      properties:
        id: { type: string, description: The person's user ID. }
        object: { type: string, enum: [member] }
        email: { type: string }
        name: { type: string }
        role: { type: string, enum: [owner, admin, editor, viewer] }
        mfa: { type: boolean, description: Whether they use two-factor authentication. }
        joined_at: { type: string, format: date-time }

    Notification:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [notification] }
        type: { type: string, description: "What it is about, such as post.approval_requested or account.password_changed." }
        org: { type: string, nullable: true, description: "The org it is about; null for an account notice." }
        org_name: { type: string }
        subject: { type: string }
        body: { type: string }
        link: { type: string, description: "The dashboard page it is about." }
        read_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    Invitation:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [invitation] }
        email: { type: string }
        role: { type: string, enum: [owner, admin, editor, viewer] }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        url: { type: string, description: The link to share; only in the response that made it. }
        emailed: { type: boolean, description: "Present when the request asked to email the link: whether it was sent." }

    UserToken:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [user_token] }
        name: { type: string, description: The device it was issued to. }
        hint: { type: string }
        livemode: { type: boolean }
        created_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time }

    AuditEvent:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [audit_event] }
        action: { type: string, example: api_key.create }
        target: { type: string, description: The object acted on. }
        user: { type: string, description: The member who acted. }
        api_key: { type: string, description: The API key that acted. }
        operator: { type: string, description: "The command the server's operator ran (araldo admin …), when neither a member nor a key acted." }
        outcome: { type: string }
        request_id: { type: string }
        detail: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }

    PublishAttempt:
      type: object
      properties:
        object: { type: string, enum: [publish_attempt] }
        attempt: { type: integer }
        started_at: { type: string, format: date-time }
        finished_at: { type: string, format: date-time }
        outcome:
          type: string
          description: "published, or the failure's kind: rate_limited, auth_revoked, rejected, transient, uncertain; unknown for an unclassified error; running while it is in progress."
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }

    EngagementReading:
      type: object
      properties:
        object: { type: string, enum: [engagement_reading] }
        read_at: { type: string, format: date-time }
        likes: { type: integer }
        reposts: { type: integer }
        replies: { type: integer }
        quotes: { type: integer }
        views: { type: integer }
        total: { type: integer }

    Preview:
      type: object
      properties:
        object: { type: string, enum: [preview] }
        valid: { type: boolean }
        renditions:
          type: array
          items:
            type: object
            properties:
              provider: { type: string }
              channel: { type: string }
              parts: { type: array, items: { type: string } }
              lengths: { type: array, items: { type: integer } }
              limit: { type: integer }
              counting: { type: string }
              violations:
                type: array
                items:
                  type: object
                  properties:
                    code: { type: string, example: too_long }
                    message: { type: string }
                    part: { type: integer }
                    media: { type: integer, description: "The position (from 1) of the media item at fault." }
                    length: { type: integer }
                    limit: { type: integer }
              notices:
                type: array
                description: What Araldo changes so the post fits this channel, such as resizing an image into a JPEG within its limits.
                items:
                  type: object
                  properties:
                    code: { type: string, example: media_resized }
                    message: { type: string }
                    media: { type: integer }

    MailProvider:
      type: object
      properties:
        provider: { type: string, example: brevo }
        name: { type: string }
        connect_fields:
          type: array
          items:
            type: object
            properties:
              Name: { type: string }
              Label: { type: string }
              Help: { type: string }
              Secret: { type: boolean }
              Optional: { type: boolean }
              Default: { type: string }

    MailAccount:
      type: object
      properties:
        id: { type: string, example: mailacct_01j9x3k2v7e8f9g0h1j2k3m4n5 }
        object: { type: string, enum: [mail_account] }
        brand: { type: string }
        livemode: { type: boolean }
        provider: { type: string }
        name: { type: string, description: "The provider account, as the provider names it." }
        from_name: { type: string }
        from_email: { type: string }
        reply_to: { type: string }
        default_audiences: { type: array, items: { type: string } }
        status: { type: string, enum: [active, needs_reauth] }
        status_note: { type: string }
        created_at: { type: string, format: date-time }

    Audience:
      type: object
      description: What a provider groups subscribers by. Never its members.
      properties:
        id: { type: string, example: "list:7" }
        name: { type: string }
        kind: { type: string, example: list }
        size: { type: integer, nullable: true, description: "Subscribers, when the provider says." }

    EmailTheme:
      type: object
      properties:
        logo: { type: string, nullable: true }
        accent: { type: string }
        postal_address: { type: string }
        footer: { type: string }

    IssueStatus:
      type: string
      enum: [draft, pending_approval, scheduled, sending, sent, partially_sent, canceled, failed]

    IssueInput:
      type: object
      required: [subject, body]
      properties:
        subject: { type: string, maxLength: 200 }
        preview_text: { type: string, maxLength: 200, description: The line inboxes show after the subject. }
        body: { type: string, maxLength: 100000 }
        deliveries:
          type: array
          description: The mail accounts it goes to, each once, with audiences there (empty for the account's defaults).
          items:
            type: object
            required: [mail_account]
            properties:
              mail_account: { type: string }
              audiences: { type: array, items: { type: string } }

    IssuePreview:
      type: object
      properties:
        object: { type: string, enum: [newsletter_preview] }
        html: { type: string }
        text: { type: string }

    MailResults:
      type: object
      description: Counts as providers report them. Opens are inflated by mail clients that load images on their own; clicks are the better signal.
      properties:
        recipients: { type: integer }
        delivered: { type: integer }
        opens: { type: integer }
        clicks: { type: integer }
        unsubscribes: { type: integer }
        bounces: { type: integer }
        complaints: { type: integer }

    IssueDelivery:
      type: object
      properties:
        id: { type: string, example: nldel_01j9x3k2v7e8f9g0h1j2k3m4n5 }
        object: { type: string, enum: [newsletter_delivery] }
        mail_account: { type: string, nullable: true, description: Null once the account is disconnected. }
        provider: { type: string }
        account_name: { type: string }
        audiences: { type: array, items: { $ref: "#/components/schemas/Audience" } }
        status: { type: string, enum: [draft, held, queued, handed_off, sent, canceled, failed, needs_attention] }
        campaign_id: { type: string, description: "The campaign at the provider, once handed off." }
        error: { type: string }
        sent_at: { type: string, format: date-time, nullable: true }
        results: { $ref: "#/components/schemas/MailResults" }
        results_read_at: { type: string, format: date-time, nullable: true }

    Issue:
      type: object
      properties:
        id: { type: string, example: nl_01j9x3k2v7e8f9g0h1j2k3m4n5 }
        object: { type: string, enum: [newsletter_issue] }
        brand: { type: string }
        livemode: { type: boolean }
        subject: { type: string }
        preview_text: { type: string }
        body: { type: string }
        status: { $ref: "#/components/schemas/IssueStatus" }
        send_at: { type: string, format: date-time, nullable: true }
        approval:
          type: object
          properties:
            required: { type: boolean }
            reviewed_by: { type: string }
            reviewed_by_key: { type: string }
            reviewed_at: { type: string, format: date-time }
            note: { type: string }
        deliveries: { type: array, items: { $ref: "#/components/schemas/IssueDelivery" } }
        results: { $ref: "#/components/schemas/MailResults" }
        media: { type: array, items: { type: string }, description: The library images it uses. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Pair:
      type: object
      description: A figure for the period and for the one before it.
      properties:
        value: { type: integer }
        previous: { type: integer }

    ReportEngaged:
      type: object
      properties:
        id: { type: string }
        label: { type: string }
        provider: { type: string }
        likes: { type: integer }
        reposts: { type: integer }
        replies: { type: integer }
        quotes: { type: integer }

    ReportAnalyticsRow:
      type: object
      properties:
        id: { type: string }
        label: { type: string }
        visitors: { type: integer }
        visits: { type: integer }
        conversions: { type: integer }
        goals: { type: object, additionalProperties: { type: integer } }

    Report:
      type: object
      properties:
        object: { type: string, enum: [report] }
        brand: { type: string }
        since: { type: string, format: date }
        until: { type: string, format: date }
        previous:
          type: object
          properties:
            since: { type: string, format: date }
            until: { type: string, format: date }
        settling: { type: boolean, description: "The period takes in the last week, whose analytics and ad figures sources still revise." }
        publishing:
          type: object
          nullable: true
          properties:
            published: { $ref: "#/components/schemas/Pair" }
            failed: { $ref: "#/components/schemas/Pair" }
            by_network:
              type: array
              items:
                type: object
                properties:
                  provider: { type: string }
                  published: { type: integer }
                  failed: { type: integer }
        engagement:
          type: object
          nullable: true
          description: The latest engagement of posts published in the period.
          properties:
            likes: { $ref: "#/components/schemas/Pair" }
            reposts: { $ref: "#/components/schemas/Pair" }
            replies: { $ref: "#/components/schemas/Pair" }
            quotes: { $ref: "#/components/schemas/Pair" }
            views: { $ref: "#/components/schemas/Pair" }
            top_posts: { type: array, items: { $ref: "#/components/schemas/ReportEngaged" } }
            by_channel: { type: array, items: { $ref: "#/components/schemas/ReportEngaged" } }
        web:
          type: object
          nullable: true
          properties:
            visitors: { $ref: "#/components/schemas/Pair" }
            signups: { $ref: "#/components/schemas/Pair" }
            untagged: { $ref: "#/components/schemas/Pair" }
            top_sources: { type: array, items: { $ref: "#/components/schemas/ReportAnalyticsRow" } }
            top_campaigns: { type: array, items: { $ref: "#/components/schemas/ReportAnalyticsRow" } }
            top_posts: { type: array, items: { $ref: "#/components/schemas/ReportAnalyticsRow" } }
        ads:
          type: object
          nullable: true
          properties:
            totals:
              type: array
              description: One per currency; amounts are never converted.
              items:
                type: object
                properties:
                  currency: { type: string }
                  spend: { $ref: "#/components/schemas/Pair" }
                  clicks: { $ref: "#/components/schemas/Pair" }
                  signups: { $ref: "#/components/schemas/Pair" }
                  cost_per_click: { $ref: "#/components/schemas/Pair" }
                  cost_per_signup: { $ref: "#/components/schemas/Pair" }
            campaigns: { type: array, items: { type: object } }
        newsletters:
          type: object
          nullable: true
          properties:
            issues: { $ref: "#/components/schemas/Pair" }
            delivered: { $ref: "#/components/schemas/Pair" }
            clicks: { $ref: "#/components/schemas/Pair" }
            unsubscribes: { $ref: "#/components/schemas/Pair" }
            sent:
              type: array
              items:
                type: object
                properties:
                  id: { type: string }
                  subject: { type: string }
                  send_at: { type: string, format: date-time, nullable: true }
                  results: { $ref: "#/components/schemas/MailResults" }

    EventType:
      type: string
      enum:
        - post.created
        - post.approval_requested
        - post.approved
        - post.rescheduled
        - post.rejected
        - post.published
        - post.partially_published
        - post.failed
        - post.canceled
        - post_target.published
        - post_target.failed
        - post_target.needs_attention
        - channel.connected
        - channel.needs_reauth
        - template.version_created
        - newsletter.created
        - newsletter.updated
        - newsletter.approval_requested
        - newsletter.approved
        - newsletter.rejected
        - newsletter.scheduled
        - newsletter.rescheduled
        - newsletter.unscheduled
        - newsletter.canceled
        - newsletter.sent
        - newsletter.failed
        - webhook_endpoint.disabled

    Event:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [event] }
        type: { $ref: "#/components/schemas/EventType" }
        livemode: { type: boolean }
        request_id: { type: string }
        created_at: { type: string, format: date-time }
        data:
          type: object
          properties:
            object: { type: object, additionalProperties: true }

    WebhookEndpointInput:
      type: object
      properties:
        url: { type: string }
        description: { type: string }
        enabled_events:
          type: array
          items: { type: string }
          description: Event types, or ["*"] (default).

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [webhook_endpoint] }
        url: { type: string }
        description: { type: string }
        enabled_events: { type: array, items: { type: string } }
        status: { type: string, enum: [enabled, disabled] }
        disabled_reason: { type: string }
        livemode: { type: boolean }
        secret: { type: string, description: Only when created or rolled. }
        created_at: { type: string, format: date-time }

    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [webhook_delivery] }
        event: { type: string }
        event_type: { type: string }
        status: { type: string, enum: [pending, delivering, succeeded, failed] }
        attempts: { type: integer }
        response_status: { type: integer }
        response_body: { type: string }
        error: { type: string }
        duration_ms: { type: integer }
        last_attempt_at: { type: string, format: date-time }
        next_attempt_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
