openapi: 3.2.1
info:
  title: staqly API
  version: 1.0.0
  license: { name: AGPL-3.0-only, identifier: AGPL-3.0-only }
  description: |
    Local-first workspace where people and AI agents write posts, schedule them and
    publish them to connected channels.

    **Staqly Community Edition** (local mode, the default, `STAQLY_MODE=local`; one workspace, no plans):
    every request needs the install's access token, `Authorization: Bearer
    stk_local_...`. It is made on first start, kept in `DATA_DIR/access_token`
    and printed at every start; `staqly serve token` (or `docker exec staqly
    server token`) prints it again, `--rotate` replaces it. A browser trades it
    for a session cookie with `POST /v1/sessions/api-key`.

    **Cloud mode** (`STAQLY_MODE=cloud`, hosted, many workspaces): anyone can
    create a workspace (unless SETUP_TOKEN restricts signup); it starts with a
    free 7-day Starter trial (Plus, Pro and Business are paid from the first day). Plans: Starter, Plus, Pro and Business,
    billed monthly or yearly (20 % off or more). When the trial or a paid period ends
    unpaid, the workspace is suspended: read-only, other changes return **402**
    `plan_expired` until a plan is paid for (`POST /v1/billing/checkout`).
    Every API key and session belongs to one workspace. Authenticate
    automations with `Authorization: Bearer {API_KEY}`. The dashboard uses an
    HttpOnly session cookie and must send `X-CSRF-Token` on unsafe requests.
    Plan limits are enforced per workspace; exceeding one returns **402**
    `plan_limit_exceeded` with `details`.

    Other devices (phones) join a workspace through one-time "connect a
    device" links (`/v1/device-links`), in both modes.

    Unsafe requests accept an `Idempotency-Key` header; repeating the same key
    with the same method, path, query string and body replays the original
    response for 24 hours. Responses that carry a secret (new API keys,
    recovery keys, device links, webhook signing secrets) and responses over
    1 MiB are never stored: repeating such a request runs it again.

    Any request may answer **503** `busy` with `Retry-After` when the
    database stays locked by another process for too long; retry it.

    A state that is already set cannot be set again: archiving an archived
    collection, enabling an enabled webhook or alert channel, revoking a
    revoked API key or session, reporting a result a target already has or
    choosing the plan the workspace is on answers **409** `conflict` (e.g.
    "collection is already archived") and changes nothing. A PATCH that
    changes other fields too is accepted; the unchanged state is ignored.
servers:
  # First: the docs' API playground sends requests from the browser, which only
  # cloud mode allows (CORS for docs.staqly.ai, Authorization: Bearer <API key>).
  - url: https://{host}
    description: Your staqly cloud instance (API key required)
    variables:
      host: { default: staqly.example.com, description: Host name of your cloud instance }
  - url: http://localhost:3333
    description: Local (staqly serve or Docker), the access token as bearer
security:
  - bearer: []    # cloud mode: API key; local mode: the access token
  - session: []   # dashboard session cookie (+ X-CSRF-Token on unsafe requests)
tags:
  - name: setup
    description: Instance status, signup and plans
  - name: auth
    description: Sessions, devices, API keys, the workspace, its plan and exports
  - name: accounts
    description: "Cloud accounts: sign-in with Google, workspaces, members, roles and invitations (docs: Accounts, workspaces and roles)"
  - name: collections
    description: Collections that group posts
  - name: posts
    description: Posts, their targets, scheduling and publishing
  - name: channels
    description: Connected accounts (channels) and the platform catalog
  - name: schedules
    description: Publishing schedules
  - name: jobs
    description: The background queue
  - name: media
    description: Uploaded files and thumbnails
  - name: events
    description: The workspace activity log
  - name: webhooks
    description: >
      Outgoing webhooks and their deliveries. Delivery is at-least-once and not ordered
      (deliveries run in parallel and retry): receivers deduplicate on the event `id`
      (`X-Staqly-Event-Id`) and order by its `created_at`; the body has no `seq`. See
      https://docs.staqly.ai/automation/webhooks#delivery-guarantees
  - name: alerts
    description: Alert channels (Telegram, Slack, Discord, ntfy, email)
  - name: billing
    description: Plan subscriptions through Stripe (cloud mode)
  - name: system
    description: Health, readiness and metrics

paths:
  /health:
    get:
      operationId: health
      tags: [setup]
      security: []
      summary: Liveness and database check
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [status, version], properties: { status: { type: string, const: ok }, version: { type: string } } } } } }
        "503": { description: The database is unreachable, content: { application/json: { schema: { type: object, required: [status], properties: { status: { type: string, const: unavailable } } } } } }

  /openapi.yaml:
    get:
      operationId: getOpenAPI
      tags: [setup]
      security: [{}]
      summary: This OpenAPI document
      responses:
        "200": { description: OpenAPI 3.2 YAML, content: { application/yaml: { schema: { type: string } } } }

  /v1/setup:
    get:
      operationId: getSetupStatus
      tags: [setup]
      security: []
      summary: Whether the workspace has been created
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [initialized, mode, signup_open, setup_token_required, billing_enabled, public_api_url]
                properties:
                  initialized: { type: boolean, description: Community Edition only }
                  mode: { type: string, enum: [local, cloud] }
                  signup_open: { type: boolean, description: "Cloud: anyone may create a workspace" }
                  setup_token_required: { type: boolean }
                  billing_enabled: { type: boolean, description: "Cloud with Stripe configured: paid plans can be bought (Settings → Plan & usage)" }
                  google_sign_in: { type: boolean, description: "Cloud accounts: people sign in with Google (GET /v1/auth/google/start)" }
                  email_sign_in: { type: boolean, description: "Cloud accounts: people sign in with a code sent to their email address (POST /v1/auth/email/start)" }
                  legacy_sign_in: { type: boolean, description: "Cloud: the sign-in from before accounts (admin API key, recovery key, POST /v1/workspaces) works here" }
                  passkey_sign_in: { type: boolean, description: "Cloud accounts: people sign in with a passkey (POST /v1/auth/passkey/start); needs PUBLIC_DASHBOARD_URL" }
                  public_api_url: { type: string, description: "PUBLIC_API_URL: the API's base URL for agents and tools (shown in dashboard snippets)" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/workspaces:
    get:
      operationId: listMyWorkspaces
      tags: [accounts]
      summary: Workspaces the caller can open, with their role
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/MyWorkspace" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createWorkspace
      tags: [setup]
      security: []
      summary: Create a cloud workspace (with billing it has no plan until a subscription starts; without billing the server-side trial)
      description: >-
        Without cloud accounts (AUTH_DEV_SIGNIN) only; with Google sign-in new people start at GET /v1/auth/google/start (409 here) and account owners add workspaces with POST /v1/account/workspaces. Cloud mode only; each call" creates a new isolated workspace. Rate-limited to 5 per hour per client IP (429). Requires `setup_token` when the server sets SETUP_TOKEN. Creates the workspace, an admin API key (scope `admin`, the credential that signs in to the dashboard; see POST /v1/sessions/api-key), a single-use recovery key and a browser session cookie. Secrets are returned only in this response.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 100, description: Workspace name (defaults to "My Workspace") }
                setup_token: { type: string, description: Required when the server sets SETUP_TOKEN }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [workspace, api_key, recovery_key, csrf_token]
                properties:
                  workspace: { $ref: "#/components/schemas/Workspace" }
                  api_key: { $ref: "#/components/schemas/APIKey" }
                  recovery_key: { type: string }
                  csrf_token: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/plans:
    get:
      operationId: listPlans
      tags: [setup]
      security: [{}]
      summary: Cloud plans and their limits (-1 = unlimited)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Plan" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/workspace/usage:
    get:
      operationId: getWorkspaceUsage
      tags: [auth]
      summary: Current plan and usage (the Community Edition reports the unlocked plan, named Community Edition)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [plan, period, usage, plan_expires_at, trial_ends_at, suspended, posts_by_status, webhook_deliveries]
                properties:
                  plan: { $ref: "#/components/schemas/Plan" }
                  period: { type: string, examples: ["2026-10"] }
                  usage:
                    type: object
                    required: [posts_per_month, channels, seats, alerts, webhooks, api_keys, storage_bytes, x_posts_per_period]
                    properties:
                      posts_per_month: { type: integer, description: Posts created this month }
                      channels: { type: integer, description: Connected channels }
                      seats: { type: integer, description: "Seats billed: the workspace members (each billed per user)" }
                      alerts: { type: integer }
                      webhooks: { type: integer }
                      api_keys: { type: integer }
                      storage_bytes: { type: integer }
                      x_posts_per_period: { type: integer, description: "Posts to X this month (a post with a live link counts 14)" }
                  plan_expires_at:
                    type: [string, "null"]
                    format: date-time
                    description: |
                      End of the free trial or paid period; null = no end. A Stripe-billed plan adds a 3-day payment grace
                      after its paid or trial period, so count a trial down to `trial_ends_at` instead.
                  trial_ends_at:
                    type: [string, "null"]
                    format: date-time
                    description: |
                      When the 7-day free trial ends: Stripe's `trial_end` for a Stripe trial, the end of the server-side
                      trial on a server without billing; null when the workspace is in no trial.
                  suspended:
                    type: boolean
                    description: |
                      The trial or paid period ended unpaid. The workspace is read-only:
                      reads and exports work, other changes answer 402 `plan_expired` until it is
                      a plan is paid for (`POST /v1/billing/checkout`).
                  posts_by_status:
                    type: object
                    description: Posts created this period, counted by their current status (statuses with no posts are left out).
                    additionalProperties: { type: integer }
                    examples: [{ draft: 2, scheduled: 2, published: 1 }]
                  webhook_deliveries:
                    type: object
                    description: Webhook deliveries right now. `failed` gave up after every retry and can be redelivered.
                    required: [queued, retrying, failed]
                    properties:
                      queued: { type: integer, description: Waiting for their first attempt }
                      retrying: { type: integer, description: "Failed at least once; another attempt is scheduled" }
                      failed: { type: integer, description: Gave up after every retry }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/billing:
    get:
      operationId: getBilling
      tags: [billing]
      summary: Subscription status (admin)
      description: |
        Whether billing is enabled on this instance (cloud mode with Stripe configured), the
        plan, Stripe's subscription status and the end of the trial or paid period.
      responses:
        "200":
          description: Billing status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Billing" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/billing/details:
    get:
      operationId: getBillingDetails
      tags: [billing]
      summary: Subscription period, cards on file and invoices (admin)
      description: |
        Read from Stripe on every request and never stored: the current subscription (status, billing period,
        cancellation, trial end, price), the cards on file (brand, last four digits, expiry) and the newest
        invoices, 24 per page. Without billing or before the first checkout the lists are empty.
      parameters:
        - name: starting_after
          in: query
          required: false
          description: The id of the last invoice of the previous page; the answer then lists only invoices
          schema: { type: string, examples: [in_1Q2w3E4r5T6y7U8i] }
      responses:
        "200":
          description: Billing details
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingDetails" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/BadGateway" }

  /v1/billing/checkout:
    post:
      operationId: createCheckoutSession
      tags: [billing]
      summary: Pay for a plan, or switch the subscription to another plan (account owner)
      description: |
        Without a live subscription: returns a Stripe Checkout URL for the plan billed monthly or yearly;
        open it in a browser to pay. Prices are per seat (the members) and exclude VAT (Stripe Tax adds it from
        the billing address). Starter starts Stripe's 7-day trial with a card required (nothing is charged until
        it ends; cancelled if no card is on file then), once per workspace; Plus, Pro and Business are charged at
        once. Stripe redirects back to `<PUBLIC_DASHBOARD_URL>/settings/plan?checkout=success` (or the welcome
        steps with `return_to: welcome`), and the plan changes when Stripe's webhook arrives. With billing on, a
        new workspace has no plan (read-only, 402 `plan_expired` on writes) until a subscription starts.

        With a live subscription: switches it to the plan and interval (Stripe prorates the difference) and
        returns the dashboard's plan page URL (`/settings/plan`) with `?checkout=switched`; the plan changes when Stripe's
        `customer.subscription.updated` event arrives. 409 for the plan and interval it already has.

        Allowed while suspended. 409 `not_available` when billing is off or that price is not set up.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                plan: { type: string, enum: [starter, plus, pro, business], default: pro }
                interval: { type: string, enum: [month, year], default: month, description: "year: billed yearly, twelve whole months at 20 % off or more" }
                return_to: { type: string, enum: [welcome], description: "welcome: Stripe sends the person back to the dashboard's welcome steps instead of the plan page" }
      responses:
        "200":
          description: Checkout URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingURL" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/BadGateway" }

  /v1/billing/x-posts:
    get:
      operationId: getXPosts
      tags: [billing]
      summary: Posts to X this period (posts:read)
      description: >-
        Posts to X are metered: each plan includes a number per month and bought bundles add more (used after the
        included ones, oldest first, expiring at the end of the billing period). A post or thread reply counts once
        when it is published (a post with a live link counts 14: X bills it at $0.20 against $0.015).
      responses:
        "200":
          description: This period's posts to X
          content:
            application/json:
              schema: { $ref: "#/components/schemas/XPosts" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/billing/credits/checkout:
    post:
      operationId: createCreditsCheckout
      tags: [billing]
      summary: Buy extra posts to X (account owner)
      description: Returns a Stripe Checkout URL (one-time payment) for a bundle; the posts are added when Stripe reports the payment.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [posts]
              properties:
                provider: { type: string, enum: [x], description: "Default x" }
                posts: { type: integer, enum: [200, 1000, 4000] }
      responses:
        "200":
          description: Checkout URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingURL" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
  /v1/billing/addons:
    get:
      operationId: listAddons
      tags: [billing]
      summary: Add-ons on sale and which the workspace has (admin)
      description: Products sold on top of a plan (Stripe products with staqly_kind=addon). Their features raise limits or switch capabilities on; the workspace's plan shows the result. Empty without billing.
      responses:
        "200":
          description: Add-ons
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Addon" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/billing/addons/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, examples: { pack: { value: channels_pack } } }
    post:
      operationId: addAddon
      tags: [billing]
      summary: Add an add-on to the subscription (account owner)
      description: Adds it to the live subscription in the subscription's interval, charged at once for the rest of the period with the card on file. Its features apply when Stripe reports them (seconds).
      responses:
        "204": { description: Added }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
    delete:
      operationId: removeAddon
      tags: [billing]
      summary: Remove an add-on (account owner)
      description: Removes it from the subscription now; the unused rest of the period is credited on the next invoice.
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/billing/portal:
    post:
      operationId: createPortalSession
      tags: [billing]
      summary: Open the Stripe customer portal (admin)
      description: Returns a portal URL to change the payment method, see invoices or cancel. 409 before the first checkout.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200":
          description: Portal URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingURL" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/BadGateway" }

  /v1/billing/webhook:
    post:
      operationId: stripeWebhook
      tags: [billing]
      summary: Stripe webhook endpoint
      description: |
        Called by Stripe, not by clients. Verified with the `Stripe-Signature` header and
        `STRIPE_WEBHOOK_SECRET`; each event is applied once. Handles
        `checkout.session.completed` and `customer.subscription.*`.
      security: []
      parameters:
        - { name: Stripe-Signature, in: header, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200":
          description: Received
          content:
            application/json:
              schema:
                type: object
                required: [received]
                properties:
                  received: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/BadGateway" }

  /v1/device-links:
    post:
      operationId: createDeviceLink
      tags: [auth]
      summary: Create a one-time "connect a device" link (QR code)
      description: Dashboard only (session or local principal). The token is in the URL fragment, valid for 5 minutes, single use.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [url, expires_at, reachable]
                properties:
                  url: { type: string, examples: ["https://staqly.example.com/connect#abc..."] }
                  expires_at: { type: string, format: date-time }
                  reachable: { type: boolean, description: false when the URL points at localhost }
                  hint: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/device-links/redeem:
    post:
      operationId: redeemDeviceLink
      tags: [auth]
      security: [{}]
      summary: Exchange a device-link token for a session cookie
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [token], properties: { token: { type: string } } }
      responses:
        "201": { description: Session cookie set, content: { application/json: { schema: { type: object, required: [csrf_token], properties: { csrf_token: { type: string } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "410": { $ref: "#/components/responses/Gone" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/sessions/recover:
    post:
      operationId: recoverSession
      tags: [auth]
      security: []
      summary: Recover access with the recovery key (lost API key)
      description: |
        The recovery key is single-use. In one transaction this sets a session
        cookie, creates a new admin API key (not counted against the plan's key
        limit), replaces the recovery key with a new one (the old one stops
        working) and signs out every other session of the workspace. On a plan
        with a key limit (Starter, Plus) the new admin key replaces the old admin keys,
        which are revoked; otherwise existing API keys keep working. The new API key and recovery key are returned only
        in this response. Rate-limited per client IP (429). Cloud only: the Community Edition (local mode) has no
        recovery key and no API keys (409); it signs in with its access token.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [recovery_key], properties: { recovery_key: { type: string } } }
      responses:
        "201":
          description: Session cookie set; the new admin key and the next recovery key, shown once
          content:
            application/json:
              schema:
                type: object
                required: [csrf_token, api_key, recovery_key]
                properties:
                  csrf_token: { type: string }
                  api_key: { $ref: "#/components/schemas/APIKey" }
                  recovery_key: { type: string, description: Replaces the recovery key that was just used }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: "`sign_in_with_account`: the workspace belongs to an account; people sign in with their sign-in methods", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/sessions/api-key:
    post:
      operationId: signInWithAPIKey
      tags: [auth]
      security: []
      summary: Sign in to the dashboard with an admin API key (or, in the Community Edition, the access token)
      description: |
        Community Edition: exchanges the access token (`stk_local_...`) for a browser
        session cookie; a wrong token gets 401. Cloud mode: exchanges an API key with the
        `admin` scope (the key sign-up or recovery returns) for a browser session
        cookie. Keys without `admin` (agent keys) get 403; unknown, revoked or
        expired keys get 401. The key goes in the body, not in Authorization.
        Other sessions stay signed in. Recorded as a `session.signed_in` event.
        Rate-limited per client IP (429).
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [api_key], properties: { api_key: { type: string, description: "An admin key, stk_..." } } }
      responses:
        "201": { description: Session cookie set, content: { application/json: { schema: { type: object, required: [csrf_token], properties: { csrf_token: { type: string } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/auth/google/start:
    get:
      operationId: startGoogleSignIn
      tags: [accounts]
      security: []
      summary: Start the sign-in with Google (browser redirect)
      description: Sets a short-lived sealed cookie (state, nonce, PKCE verifier) and redirects to Google. Errors redirect to /login?error=<code>.
      parameters:
        - { name: next, in: query, schema: { type: string }, description: A path on this dashboard to land on }
        - { name: invite, in: query, schema: { type: string }, description: An invitation token to accept after signing in }
        - { name: setup_token, in: query, schema: { type: string }, description: Lets a new person create an account when SETUP_TOKEN is set }
      responses:
        "302": { description: "Redirect to Google, or to /login?error=<code>" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/auth/google/callback:
    get:
      operationId: googleSignInCallback
      tags: [accounts]
      security: []
      summary: Finish the sign-in with Google (browser redirect)
      description: Verifies state, nonce and the ID token, signs the person in (accepting an invitation, or creating their account and first workspace) with a new session cookie, and redirects to a dashboard path. Errors redirect to /login?error=<code>.
      parameters:
        - { name: state, in: query, schema: { type: string } }
        - { name: code, in: query, schema: { type: string } }
        - { name: error, in: query, schema: { type: string } }
      responses:
        "302": { description: "Redirect to the dashboard or to /login?error=<code>" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/auth/email/start:
    post:
      operationId: startEmailSignIn
      tags: [accounts]
      security: []
      summary: Send a sign-in code to an email address
      description: |
        Mails a 6-digit code (valid 10 minutes, single use, 5 tries). The answer is the same whether or not
        the address has an account. A new code replaces the previous one. Limits: one code per address every
        30 seconds (429 `code_cooldown`, `details.resend_in`), 5 per address per hour, and a limit per client IP.
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [email], properties: { email: { type: string } } } } }
      responses:
        "202": { description: Code sent, content: { application/json: { schema: { $ref: "#/components/schemas/EmailCodeSent" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { description: "The email could not be sent", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /v1/auth/email/verify:
    post:
      operationId: verifyEmailSignIn
      tags: [accounts]
      security: []
      summary: Sign in with the emailed code
      description: |
        Signs the person in as the identity (email, address) with a new session cookie: a returning person
        lands in their workspace, an invitation (`invite`) is accepted, a new person gets an account and a first
        workspace (when sign-ups are open or with `setup_token`). Never merged with a Google identity of the same
        address. A wrong code answers 401 `code_invalid` with `details.attempts_left`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string }
                code: { type: string, description: "6 digits" }
                next: { type: string, description: A path on this dashboard to land on }
                invite: { type: string, description: An invitation token to accept }
                setup_token: { type: string, description: Lets a new person create an account when SETUP_TOKEN is set }
      responses:
        "201": { description: Session cookie set, content: { application/json: { schema: { type: object, required: [csrf_token, next], properties: { csrf_token: { type: string }, next: { type: string } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "410": { description: "The code expired, was used, was replaced by a newer one or had too many wrong tries", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /v1/auth/email/dev-code:
    get:
      operationId: getDevEmailCode
      tags: [accounts]
      security: []
      summary: The last code sent to an address (AUTH_DEV_SIGNIN only, for tests)
      description: 404 on every server without AUTH_DEV_SIGNIN=true.
      parameters:
        - { name: email, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [code], properties: { code: { type: string } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/auth/passkey/start:
    post:
      operationId: startPasskeySignIn
      tags: [accounts]
      security: []
      summary: Start signing in with a passkey
      description: |
        Returns the options for `navigator.credentials.get` (WebAuthn JSON, binary fields base64url) with no
        address: the passkey names the person. The challenge works once, for 5 minutes.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PasskeyChallenge" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/auth/passkey/finish:
    post:
      operationId: finishPasskeySignIn
      tags: [accounts]
      security: []
      summary: Sign in with the browser's passkey answer
      description: |
        Checks the assertion against the person's passkey and signs them in with a new session cookie. 401
        `passkey_invalid` when it does not check out, `passkey_cloned` when the sign counter went backwards.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challenge_id, response]
              properties:
                challenge_id: { type: string }
                response: { type: object, description: "The PublicKeyCredential as JSON (credential.toJSON())" }
                next: { type: string, description: A path on this dashboard to land on }
      responses:
        "201": { description: Session cookie set, content: { application/json: { schema: { type: object, required: [csrf_token, next], properties: { csrf_token: { type: string }, next: { type: string } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "410": { description: "The passkey prompt expired or was already answered", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /v1/account/sign-in-methods:
    get:
      operationId: listSignInMethods
      tags: [accounts]
      summary: How the signed-in person signs in (Google, email addresses)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SignInMethodList" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/account/sign-in-methods/email/start:
    post:
      operationId: startEmailLink
      tags: [accounts]
      summary: Send a code to an address the signed-in person wants to sign in with too
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [email], properties: { email: { type: string } } } } }
      responses:
        "202": { description: Code sent, content: { application/json: { schema: { $ref: "#/components/schemas/EmailCodeSent" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { description: "The email could not be sent", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /v1/account/sign-in-methods/email/verify:
    post:
      operationId: verifyEmailLink
      tags: [accounts]
      summary: Add the address as a sign-in method with its code
      description: 409 `identity_taken` when the address already signs in another person.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [email, code], properties: { email: { type: string }, code: { type: string } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SignInMethodList" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "410": { description: "The code expired, was used, was replaced by a newer one or had too many wrong tries", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /v1/account/sign-in-methods/email/change/current/start:
    post:
      operationId: startEmailChange
      tags: [accounts]
      summary: Changing the email sign-in address, step 1 - send a code to the current address
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [email], properties: { email: { type: string, description: The address the person signs in with now } } } } }
      responses:
        "202": { description: Code sent, content: { application/json: { schema: { $ref: "#/components/schemas/EmailCodeSent" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { description: "The email could not be sent", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /v1/account/sign-in-methods/email/change/current/verify:
    post:
      operationId: verifyEmailChangeCurrent
      tags: [accounts]
      summary: Changing the email sign-in address, step 1 - confirm the current address with its code
      description: The confirmation works for 10 minutes, while the person asks for and enters the code to the new address.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [email, code], properties: { email: { type: string }, code: { type: string } } } } }
      responses:
        "204": { description: Current address confirmed }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "410": { description: "The code expired, was used, was replaced by a newer one or had too many wrong tries", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /v1/account/sign-in-methods/email/change/new/start:
    post:
      operationId: startEmailChangeNew
      tags: [accounts]
      summary: Changing the email sign-in address, step 2 - send a code to the new address
      description: 409 `change_not_verified` until the current address is confirmed (step 1).
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [email, new_email], properties: { email: { type: string }, new_email: { type: string } } } } }
      responses:
        "202": { description: Code sent, content: { application/json: { schema: { $ref: "#/components/schemas/EmailCodeSent" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { description: "The email could not be sent", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /v1/account/sign-in-methods/email/change/new/verify:
    post:
      operationId: verifyEmailChangeNew
      tags: [accounts]
      summary: Changing the email sign-in address, step 2 - change it with the new address's code
      description: |
        The person signs in with the new address from now on (the same person, sessions and other methods);
        the old address gets a notice. 409 `identity_taken` when the new address already signs in another
        person, `change_not_verified` when the step 1 confirmation expired.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [email, new_email, code], properties: { email: { type: string }, new_email: { type: string }, code: { type: string } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SignInMethodList" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "410": { description: "The code expired, was used, was replaced by a newer one or had too many wrong tries", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /v1/account/sign-in-methods/passkey/start:
    post:
      operationId: startPasskeyRegistration
      tags: [accounts]
      summary: Start adding a passkey
      description: Returns the options for `navigator.credentials.create` (a discoverable credential); passkeys the person has are excluded.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PasskeyChallenge" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/account/sign-in-methods/passkey/finish:
    post:
      operationId: finishPasskeyRegistration
      tags: [accounts]
      summary: Add the passkey from the browser's answer
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challenge_id, response]
              properties:
                challenge_id: { type: string }
                name: { type: string, description: "What the person calls it, such as MacBook (default Passkey)" }
                response: { type: object, description: "The PublicKeyCredential as JSON (credential.toJSON())" }
      responses:
        "201": { description: Added, content: { application/json: { schema: { $ref: "#/components/schemas/SignInMethodList" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "410": { description: "The passkey prompt expired or was already answered", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /v1/account/sign-in-methods/passkey/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      operationId: renamePasskey
      tags: [accounts]
      summary: Rename a passkey
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [name], properties: { name: { type: string } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SignInMethodList" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/account/sign-in-methods/google:
    post:
      operationId: startGoogleLink
      tags: [accounts]
      summary: Start adding a Google account as a sign-in method
      description: Sets the sealed sign-in cookie and returns the Google URL to send the browser to; Google comes back to /settings/account?linked=google or ?error=<code> (`identity_taken`).
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [url], properties: { url: { type: string } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { description: "Google could not be reached", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /v1/account/sign-in-methods/{provider}/{subject}:
    parameters:
      - { name: provider, in: path, required: true, schema: { type: string, enum: [google, email, passkey] } }
      - { name: subject, in: path, required: true, schema: { type: string } }
    delete:
      operationId: removeSignInMethod
      tags: [accounts]
      summary: Remove a sign-in method (409 for the last one)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SignInMethodList" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/session/workspace:
    post:
      operationId: switchWorkspace
      tags: [accounts]
      summary: Move this browser session to another workspace the person belongs to
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [workspace_id], properties: { workspace_id: { type: string } } } } }
      responses:
        "204": { description: Switched }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/account/notifications:
    get:
      operationId: getNotificationPrefs
      tags: [accounts]
      summary: The signed-in person's notification choices
      description: |
        Per event, whether the person gets an email (to the address they sign in with) and whether it shows
        in the bell. `scope` decides which posts: `mine` (posts they wrote, or their API keys wrote) or `all`
        (every post in the workspaces they edit). Channel, schedule and invitation events go to owners and admins.
        Emails need the server's mail (MAIL_*); `mail_ready` says whether it is set up.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/NotificationPrefs" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      operationId: setNotificationPrefs
      tags: [accounts]
      summary: Change the signed-in person's notification choices
      description: Events not listed keep their choice; `scope` is optional.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                scope: { type: string, enum: [mine, all] }
                events: { type: array, items: { $ref: "#/components/schemas/NotificationChoice" } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/NotificationPrefs" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/notifications/unsubscribe:
    post:
      operationId: unsubscribe
      tags: [accounts]
      security: []
      summary: Turn off one kind of email from its unsubscribe link
      description: |
        Public: the signed token from a personal email is the credential (one person, one event). Mail clients
        post here for one-click unsubscribe (RFC 8058) with `?token=`; the dashboard's unsubscribe page sends
        `{"token": ...}`. Limited per client address.
      parameters:
        - { name: token, in: query, schema: { type: string } }
      requestBody:
        content: { application/json: { schema: { type: object, properties: { token: { type: string } } } } }
      responses:
        "200": { description: The email is off, content: { application/json: { schema: { type: object, required: [event], properties: { event: { type: string } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/account:
    get:
      operationId: getAccount
      tags: [accounts]
      summary: The signed-in person's account (account owners only)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Account" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: renameAccount
      tags: [accounts]
      summary: Rename the account (account owner)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [name], properties: { name: { type: string, maxLength: 100 } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Account" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/account/workspaces:
    post:
      operationId: createAccountWorkspace
      tags: [accounts]
      summary: Add a workspace to the account (account owner; with billing it has no plan until a subscription starts, see GET /v1/billing trial)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [name], properties: { name: { type: string, maxLength: 100 }, timezone: { type: string, description: "IANA time zone name for the new workspace (default UTC)", examples: [Europe/Warsaw] } } } } }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/MyWorkspace" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/members:
    get:
      operationId: listMembers
      tags: [accounts]
      summary: People in this workspace and their roles (admin)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Member" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/members/{user_id}:
    parameters:
      - { name: user_id, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateMember
      tags: [accounts]
      summary: Change a member's role (admins up to admin; owners any role)
      description: Signed-in people only (not API keys). The last owner and the account owner keep the owner role (409).
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [role], properties: { role: { $ref: "#/components/schemas/Role" } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Member" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: removeMember
      tags: [accounts]
      summary: Remove a member, or leave (their sessions in this workspace end)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/invitations:
    get:
      operationId: listInvitations
      tags: [accounts]
      summary: Open invitations (admins and owners)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Invitation" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createInvitation
      tags: [accounts]
      summary: Invite someone by email (single-use link, 7 days)
      description: Returns the accept link once (`link`); it is also emailed when the server has mail configured (`emailed`). Plans are priced per user, so once accepted the new member is billed as a seat (the subscription's quantity follows the members, prorated). No plan caps seats.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [email, role], properties: { email: { type: string }, role: { $ref: "#/components/schemas/Role" } } } } }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Invitation" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/invitations/lookup:
    get:
      operationId: lookupInvitation
      tags: [accounts]
      security: []
      summary: What an invitation link is for (shown before signing in)
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/InvitationPreview" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/invitations/accept:
    post:
      operationId: acceptInvitation
      tags: [accounts]
      summary: Accept an invitation as the signed-in person (idempotent; moves the session there)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [token], properties: { token: { type: string } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/MyWorkspace" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "410": { description: "The invitation expired, was revoked or was already used", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/invitations/{id}/resend:
    post:
      operationId: resendInvitation
      tags: [accounts]
      summary: Issue a new link and a new expiry (the old link stops working)
      parameters:
        - { $ref: "#/components/parameters/id" }
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Invitation" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/invitations/{id}:
    delete:
      operationId: revokeInvitation
      tags: [accounts]
      summary: Revoke an open invitation
      parameters:
        - { $ref: "#/components/parameters/id" }
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/session:
    get:
      operationId: getSession
      tags: [auth]
      summary: Current principal, workspace and CSRF token
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [mode, workspace, principal, csrf_token]
                properties:
                  mode: { type: string, enum: [local, cloud] }
                  workspace: { $ref: "#/components/schemas/Workspace" }
                  principal:
                    type: object
                    required: [type, id, name, scopes]
                    properties:
                      type: { type: string, enum: [local, session, api_key] }
                      id: { type: string }
                      name: { type: string }
                      scopes: { type: array, items: { $ref: "#/components/schemas/Scope" } }
                  csrf_token: { type: string, description: Empty for API-key callers }
                  user: { $ref: "#/components/schemas/User" }
                  role: { $ref: "#/components/schemas/Role" }
                  account_owner: { type: boolean, description: "Owns the account this workspace belongs to (creates and deletes workspaces, billing)" }
                  accounts_enabled: { type: boolean, description: "Cloud accounts with Google sign-in are on" }
                  workspaces: { type: array, items: { $ref: "#/components/schemas/MyWorkspace" }, description: "Workspaces this person can switch to" }
                  onboarded: { type: boolean, description: "The welcome wizard is done (per person in an account; in the Community Edition per workspace, and a workspace with channels or posts counts as done). False sends the dashboard to /welcome once." }
                  defaults:
                    type: object
                    description: Server defaults that apply when a collection sets no value.
                    required: [processing_timeout_seconds]
                    properties:
                      processing_timeout_seconds: { type: integer, description: "PROCESSING_TIMEOUT in seconds: how long a target on your own automation waits for a result before it fails (0 = never)" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: logout
      tags: [auth]
      summary: Log out (revoke the current session)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Logged out }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/session/onboarded:
    post:
      operationId: finishOnboarding
      tags: [auth]
      summary: Finish (or skip) the welcome wizard
      description: |
        Marks the welcome wizard done for the signed-in person (the Community Edition: for the workspace), so the
        dashboard no longer opens it on its own. `name`, when given, is how the person wants to be called: the
        name of the account they own (the sidebar shows it), and theirs. Dashboard sessions only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 100, description: "Optional: the person's name" }
      responses:
        "204": { description: Done }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/sessions:
    get:
      operationId: listSessions
      tags: [auth]
      summary: List active browser sessions (admin)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Session" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/sessions/{id}:
    delete:
      operationId: revokeSession
      tags: [auth]
      summary: Revoke a session (admin)
      parameters:
        - { $ref: "#/components/parameters/id" }
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/workspace:
    get:
      operationId: getWorkspace
      tags: [auth]
      summary: Get the workspace
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Workspace" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateWorkspace
      tags: [auth]
      summary: Rename the workspace or change its time zone (admin)
      description: |
        Send the new `name`, `timezone` (an IANA name; an unknown zone is 422), or both. Fields left out stay as they are.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                timezone: { type: string, description: IANA time zone name, examples: [Europe/Warsaw] }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Workspace" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteWorkspace
      tags: [auth]
      summary: Permanently delete this workspace and all its data (cloud, dashboard session only)
      description: |
        Deletes collections, posts, channels, schedules, jobs, media files, events,
        webhooks, deliveries, alert channels, API keys, sessions and exports. Requires a
        session (API keys get 403) and `confirm` equal to the workspace name.
        The Community Edition returns 409: delete the data volume instead.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm]
              properties:
                confirm: { type: string, description: The exact workspace name }
      responses:
        "204": { description: Deleted; the session cookie is cleared }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/BadGateway" }


  /v1/recovery-key/rotate:
    post:
      operationId: rotateRecoveryKey
      tags: [auth]
      summary: Issue a new recovery key (dashboard sessions only)
      description: Every other session of the workspace is signed out. Cloud only; the Community Edition (local mode) has no recovery key (409).
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200": { description: "The new key, shown once", content: { application/json: { schema: { type: object, required: [recovery_key], properties: { recovery_key: { type: string } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/access-token:
    get:
      operationId: getAccessToken
      tags: [auth]
      summary: The Community Edition's access token (dashboard sessions and the token itself)
      description: "What agents, SDKs and the CLI send as `Authorization: Bearer <token>`. 404 in cloud mode (it uses API keys)."
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [token], properties: { token: { type: string, description: "stk_local_..." } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/access-token/rotate:
    post:
      operationId: rotateAccessToken
      tags: [auth]
      summary: Make a new access token (Community Edition)
      description: |
        The old token stops working at once; every browser session except the
        caller's is signed out. Recorded as an `access_token.rotated` event. 409
        when STAQLY_LOCAL_TOKEN fixes the token; 404 in cloud mode.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200":
          description: The new token
          content:
            application/json:
              schema:
                type: object
                required: [token, sessions_revoked]
                properties:
                  token: { type: string }
                  sessions_revoked: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/api-keys:
    get:
      operationId: listAPIKeys
      tags: [auth]
      summary: List API keys (admin)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, available_scopes]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/APIKey" } }
                  available_scopes: { type: array, items: { $ref: "#/components/schemas/Scope" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createAPIKey
      tags: [auth]
      summary: Create a scoped API key (admin). The raw key is returned once.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scopes]
              properties:
                name: { type: string }
                scopes: { type: array, minItems: 1, items: { $ref: "#/components/schemas/Scope" } }
                expires_at: { type: string, description: "RFC3339 or YYYY-MM-DD, in the future (422 otherwise); at most a year ahead (422 otherwise); omitted, the key expires in a year. Expired keys stop counting against the plan key limit" }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/APIKey" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/api-keys/{id}:
    delete:
      operationId: revokeAPIKey
      tags: [auth]
      summary: Revoke an API key (admin)
      parameters:
        - { $ref: "#/components/parameters/id" }
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/api-keys/{id}/rotate:
    post:
      operationId: rotateAPIKey
      tags: [auth]
      summary: Revoke a key and issue a replacement with the same name and scopes (admin)
      description: Cloud only; the Community Edition (local mode) has no API keys (409).
      parameters:
        - { $ref: "#/components/parameters/id" }
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "201": { description: New key, content: { application/json: { schema: { $ref: "#/components/schemas/APIKey" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/collections:
    get:
      operationId: listCollections
      tags: [collections]
      summary: List collections (collections:read)
      parameters:
        - { name: include_archived, in: query, schema: { type: boolean } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Collection" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createCollection
      tags: [collections]
      summary: Create a collection (collections:write)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/CollectionInput" }, { required: [name] }] } } } }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Collection" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/collections/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getCollection
      tags: [collections]
      summary: Get a collection
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Collection" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateCollection
      tags: [collections]
      summary: Update (rename, describe, colour, processing timeout, archive)
      description: Archiving the Default collection (`is_default`) answers 409.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CollectionInput" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Collection" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteCollection
      tags: [collections]
      summary: Permanently delete a collection and its posts (collections:delete)
      description: |
        Requires `collections:delete`; `collections:write` alone answers 403.
        The Default collection (`is_default`) cannot be deleted (409).
      parameters:
        - { name: confirm, in: query, required: true, description: Must equal the collection name, schema: { type: string } }
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/collections/{id}/posts:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: listCollectionPosts
      tags: [posts]
      summary: List posts of one collection (posts:read)
      description: The same filters as GET /v1/posts. The `{id}` may also be the collection's exact name.
      parameters:
        - { $ref: "#/components/parameters/postStatus" }
        - { $ref: "#/components/parameters/channelId" }
        - { $ref: "#/components/parameters/publishAfter" }
        - { $ref: "#/components/parameters/publishBefore" }
        - { $ref: "#/components/parameters/q" }
        - { $ref: "#/components/parameters/tag" }
        - { $ref: "#/components/parameters/postSort" }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PostPage" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/posts:
    get:
      operationId: listPosts
      tags: [posts]
      summary: List posts across collections (posts:read)
      description: |
        Ordered by publish time by default (`publish_asc`: scheduled posts first, soonest first; posts
        without a time last by creation). `q` matches words of the content; `tag` is one tag.
      parameters:
        - { name: collection_id, in: query, description: A collection id or exact name, schema: { type: string } }
        - { $ref: "#/components/parameters/postStatus" }
        - { $ref: "#/components/parameters/channelId" }
        - { $ref: "#/components/parameters/publishAfter" }
        - { $ref: "#/components/parameters/publishBefore" }
        - { $ref: "#/components/parameters/q" }
        - { $ref: "#/components/parameters/tag" }
        - { $ref: "#/components/parameters/postSort" }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PostPage" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createPost
      tags: [posts]
      summary: Create a post (posts:write; scheduling or publishing it right away also needs posts:publish)
      description: |
        A draft unless `status` is `scheduled` (needs `publish_at` in the future and at least one target)
        or `publish_now` is true (needs a target). `collection` is an id or an exact name. Media ids come
        from POST /v1/media. Each target is a channel the post goes to, with optional text of its own and
        provider settings; a target that breaks its provider's limits is a 422 keyed
        `targets.<channel_id>.content` or `targets.<channel_id>.settings.<key>`.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/PostInput" }, { required: [collection] }] } } } }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/delete:
    post:
      operationId: deletePosts
      tags: [posts]
      summary: Delete several posts (posts:delete)
      description: "Unknown ids, and ids of another workspace, are listed in `missing`. What already went out stays on the platforms."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids: { type: array, minItems: 1, maxItems: 200, items: { type: string } }
      responses:
        "200":
          description: What was deleted and which ids were not found
          content:
            application/json:
              schema:
                type: object
                required: [deleted, missing]
                properties:
                  deleted: { type: array, items: { type: string } }
                  missing: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getPost
      tags: [posts]
      summary: Get a post with its targets (posts:read)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updatePost
      tags: [posts]
      summary: Edit a draft, scheduled or failed post (posts:write)
      description: |
        Fields left out stay as they are; `targets` replaces the whole list; `publish_at: null` clears
        the time (a scheduled post becomes a draft). A published or publishing post is not editable
        (409 `not_editable`). `expected_version` makes the edit conditional (409 `post_changed`).
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PostPatch" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deletePost
      tags: [posts]
      summary: Delete a post; its events stay as history (posts:delete)
      description: Requires `posts:delete`; `posts:write` alone answers 403. A publishing post cannot be deleted (409).
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/schedule:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: schedulePost
      tags: [posts]
      summary: Set the publish time and make the post scheduled (posts:publish)
      description: "From draft, scheduled or failed; the post needs at least one target and a time in the future (at most a year ahead). A post carrying a file in Deleted is refused (422 on `media`): restore the file first."
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [publish_at], properties: { publish_at: { type: string, format: date-time } } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/publish:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: publishPost
      tags: [posts]
      summary: Publish now (posts:publish)
      description: |
        From draft, scheduled or failed. The post becomes `publishing` and a job publishes each target;
        the post ends `published` when every target is, `failed` when one is not (retry a target with
        POST /v1/posts/{id}/targets/{channel}/retry). Targets on your own automation (provider `custom`)
        emit `post.publish_requested` and wait for POST .../result. A post carrying a file in Deleted is refused
        (422 on `media`), as scheduling it is: restore the file first.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "202": { description: Publishing, content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/cancel:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: cancelPost
      tags: [posts]
      summary: Take a scheduled post off the schedule; it becomes a draft (posts:publish)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/duplicate:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: duplicatePost
      tags: [posts]
      summary: Copy content, media, tags, metadata and targets into a new draft (posts:write)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/metrics:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getPostMetrics
      tags: [posts]
      summary: The post's numbers per channel, read from each platform that reports them (posts:read)
      description: |
        Published targets on platforms that report numbers (X) are read again when the cached numbers are older
        than ten minutes, or now with `refresh=true`. A failed read keeps the last numbers and sets `error`.
        Targets on your own automation show what it reported (PUT .../metrics). `available: false` means the
        platform gives no numbers.
      parameters:
        - { name: refresh, in: query, description: "Read every published target's numbers now", schema: { type: boolean } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/TargetMetrics" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/targets/{channel}/metrics:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: channel, in: path, required: true, description: The channel id of the target, schema: { type: string } }
    put:
      operationId: reportTargetMetrics
      tags: [posts]
      summary: Your own automation sends the numbers of a post it published (posts:publish)
      description: Only targets on a channel of provider `custom` (422 otherwise). Replaces the stored numbers.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/Metrics" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/TargetMetrics" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/previews:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: listPreviewLinks
      tags: [posts]
      summary: The post's preview links, newest first; tokens are never shown again (posts:read)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/PreviewLink" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createPreviewLink
      tags: [posts]
      summary: A link to a read-only preview page of the post where anyone with it can comment (posts:write)
      description: |
        The answer carries `url` and `token` once. The token is in the URL fragment, so it does not reach
        server logs or other sites. `expires_in_days` 0 (or no body) never expires; revoke with DELETE.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: false, content: { application/json: { schema: { type: object, properties: { expires_in_days: { type: integer, minimum: 0, maximum: 365, description: "0 never expires" } } } } } }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewLink" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/previews/{preview}:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: preview, in: path, required: true, description: The preview link's id, schema: { type: string } }
    delete:
      operationId: revokePreviewLink
      tags: [posts]
      summary: Stop a preview link from working (posts:write)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/comments:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: listPostComments
      tags: [posts]
      summary: "The post's feedback threads: reviewers' comments from its preview pages (with their email) and the team's replies, oldest first (posts:read)"
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/PostComment" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: addPostComment
      tags: [posts]
      summary: Comment on the post's feedback as the team (a reply with parent_id, or a new thread) (posts:write)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 2000 }
                parent_id: { type: string, description: Reply to this thread }
                quote: { type: string, maxLength: 500, description: "A new thread's passage; it must be in the text named by channel_id and part" }
                channel_id: { type: string }
                part: { type: integer, minimum: 0 }
                anchor_start: { type: integer }
                anchor_end: { type: integer }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/PostComment" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/comments/{comment}/resolve:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: comment, in: path, required: true, description: "The thread's first comment", schema: { type: string } }
    post:
      operationId: resolvePostComment
      tags: [posts]
      summary: Resolve a feedback thread (posts:write)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PostComment" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/comments/{comment}/reopen:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: comment, in: path, required: true, description: "The thread's first comment", schema: { type: string } }
    post:
      operationId: reopenPostComment
      tags: [posts]
      summary: Reopen a resolved feedback thread (posts:write)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PostComment" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/comments/{comment}:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: comment, in: path, required: true, description: The comment's id, schema: { type: string } }
    delete:
      operationId: deletePostComment
      tags: [posts]
      summary: "Remove a comment (posts:write); removing a thread's first comment removes its replies"
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/previews/{token}:
    parameters:
      - { name: token, in: path, required: true, description: The preview link's token, schema: { type: string } }
    get:
      operationId: openPreview
      tags: [posts]
      security: []
      summary: The preview page's content, as each channel will show the post (no credentials; the token is the key)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreview" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/previews/{token}/comments:
    parameters:
      - { name: token, in: path, required: true, description: The preview link's token, schema: { type: string } }
    post:
      operationId: addPreviewComment
      tags: [posts]
      security: []
      summary: Leave a comment on a previewed post (no credentials; 20 per 10 minutes per address, 30 an hour per link)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, body]
              properties:
                name: { type: string, maxLength: 80, description: Who is commenting }
                email: { type: string, description: "Optional; shown to the team only" }
                body: { type: string, maxLength: 2000 }
                quote: { type: string, maxLength: 500, description: "The words of the post the comment is about; they must be in the text named by channel_id and part" }
                channel_id: { type: string, description: The channel's version the comment is about }
                part: { type: integer, minimum: 0, description: "0: the post's text (default); n: the n-th comment of its thread" }
                anchor_start: { type: integer, description: "Where the quote starts (UTF-16 code units); when it doesn't match, the quote's first occurrence is used" }
                anchor_end: { type: integer, description: Where it ends (exclusive) }
                parent_id: { type: string, description: "Reply to this thread (a reply takes no quote)" }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/PostComment" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/previews/{token}/comments/{comment}/resolve:
    parameters:
      - { name: token, in: path, required: true, description: The preview link's token, schema: { type: string } }
      - { name: comment, in: path, required: true, description: "The thread's first comment", schema: { type: string } }
    post:
      operationId: resolvePreviewComment
      tags: [posts]
      security: []
      summary: Resolve a feedback thread from the preview page (no credentials; counted with the comments per address)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 80, description: Who resolves it }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PostComment" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/previews/{token}/comments/{comment}/reopen:
    parameters:
      - { name: token, in: path, required: true, description: The preview link's token, schema: { type: string } }
      - { name: comment, in: path, required: true, description: "The thread's first comment", schema: { type: string } }
    post:
      operationId: reopenPreviewComment
      tags: [posts]
      security: []
      summary: Reopen a resolved feedback thread from the preview page (no credentials)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PostComment" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/previews/{token}/media/{media}:
    parameters:
      - { name: token, in: path, required: true, description: The preview link's token, schema: { type: string } }
      - { name: media, in: path, required: true, description: A media id of the previewed post, schema: { type: string } }
    get:
      operationId: getPreviewMedia
      tags: [posts]
      security: []
      summary: A picture or video of the previewed post (its own or its comments' media only)
      responses:
        "200": { description: The file, content: { "*/*": { schema: { type: string, format: binary } } } }
        "206": { description: Part of the file (Range) }
        "304": { description: Not modified }
        "404": { $ref: "#/components/responses/NotFound" }
        "416": { description: Range not satisfiable }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/events:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: listPostEvents
      tags: [posts]
      summary: The post's history, newest first (events:read)
      parameters:
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/eventCursor" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/EventPage" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/preview:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: previewPost
      tags: [posts]
      summary: The text each target will carry and what its provider objects to (posts:read)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PostPreview" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/targets/{channel}/result:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: channel, in: path, required: true, description: The channel id of the target, schema: { type: string } }
    post:
      operationId: reportTargetResult
      tags: [posts]
      summary: Close a target on your own automation that waits for a result (posts:publish)
      description: |
        Only targets on a channel of provider `custom` take reported results, and only while they are
        `publishing` (409 otherwise). `published` needs no more; `failed` takes an `error`. The post
        settles when its last target does.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [status], properties: { status: { type: string, enum: [published, failed] }, url: { type: string, description: "Where the post lives now" }, external_id: { type: string, description: "The platform's id of the post" }, error: { type: string, description: "Why it failed" } } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/targets/{channel}/retry:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: channel, in: path, required: true, description: The channel id of the target, schema: { type: string } }
    post:
      operationId: retryTarget
      tags: [posts]
      summary: Try a failed target once more, now (posts:publish)
      description: A post carrying a file in Deleted is refused (422 on `media`); restore the file first.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "202": { description: Publishing, content: { application/json: { schema: { $ref: "#/components/schemas/Post" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/posts/{id}/purge:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: purgePost
      tags: [posts]
      summary: Delete a post and its history, including every event about it (admin)
      description: |
        Unlike DELETE /v1/posts/{id} (which keeps the post's events as history), purge also deletes
        the post's events and their webhook deliveries (including pending ones), its media when no
        other post uses them, and cached idempotent responses containing it. One content-free
        `post.purged` event is recorded. Finished workspace exports are expired.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PurgeResult" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/tags:
    get:
      operationId: listTags
      tags: [posts]
      summary: List the workspace's post tags, most used first (posts:read)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Tag" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/work:
    get:
      operationId: listRequestedWork
      tags: [posts]
      summary: What the workspace asks your automation for (posts:read)
      description: |
        Targets on your own automation (provider `custom`) that are publishing and wait for a result
        (`kind: target`, with the post and the channel), and schedules of `source: agent` whose last run
        found nothing to publish (`kind: posts`, with the schedule). Each entry says when it was requested.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/WorkItem" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/providers:
    get:
      operationId: listProviders
      tags: [channels]
      summary: The platform catalog (channels:read)
      description: |
        Every platform staqly knows, with `status` available (can be connected) or soon, how it
        authenticates (`oauth2`, `token` or `none`), its posting limits, the fields a connection
        needs (token platforms) and the per-target settings its posts take. `app_configured` says
        whether an OAuth platform has a developer app (the workspace's own or the server's
        PROVIDER_<ID>_CLIENT_ID / _CLIENT_SECRET).
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Provider" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/providers/{id}/app:
    parameters: [{ $ref: "#/components/parameters/id" }]
    put:
      operationId: setProviderApp
      tags: [channels]
      summary: Register the workspace's own developer app for an OAuth platform (channels:manage)
      description: The client secret is stored sealed and never returned. Needed when the server has no app for the platform (the Community Edition).
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/DeveloperAppInput" }, { required: [client_id, client_secret] }] } } } }
      responses:
        "204": { description: Saved }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteProviderApp
      tags: [channels]
      summary: Remove the workspace's developer app for a platform (channels:manage)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/channels:
    get:
      operationId: listChannels
      tags: [channels]
      summary: List connected channels (channels:read)
      description: Each channel comes with its `group_id` and `activity` (read from its targets). `group` keeps one channel group.
      parameters:
        - { name: group, in: query, schema: { type: string }, description: Only channels in this channel group (grp_...) }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Channel" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: connectChannel
      tags: [channels]
      summary: Connect a channel on a token platform, or your own automation (channels:manage)
      description: |
        `fields` carries the provider's `credential_fields` (a bot token, an instance URL...); the
        platform is asked for the account's profile before the channel is saved, and the credentials
        are stored sealed. Provider `custom` (your own automation) takes no fields. OAuth platforms
        connect through GET /v1/channels/oauth/{provider}/start instead (422 here).
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/ChannelInput" }, { required: [provider] }] } } } }
      responses:
        "201": { description: Connected, content: { application/json: { schema: { $ref: "#/components/schemas/Channel" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/channels/oauth/{provider}/start:
    parameters: [{ name: provider, in: path, required: true, schema: { type: string } }]
    get:
      operationId: startChannelOAuth
      tags: [channels]
      summary: Begin connecting an OAuth platform (channels:manage)
      description: |
        Answers the platform's authorization URL to send the browser to. The state is signed and
        bound to this browser with a short-lived cookie. Other query parameters are passed to the
        provider (`instance` for a Mastodon-style platform, say). The platform sends the browser back
        to the dashboard's `/channels/callback/{provider}` page, which hands the answer to the callback below.
      parameters:
        - { name: instance, in: query, description: "Platforms with many servers (Mastodon): the server's URL or host", schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [url], properties: { url: { type: string, format: uri } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/channels/oauth/{provider}/callback:
    parameters: [{ name: provider, in: path, required: true, schema: { type: string } }]
    get:
      operationId: finishChannelOAuth
      tags: [channels]
      summary: Finish connecting an OAuth platform (channels:manage)
      description: |
        Receives the platform's `code` and `state` (or `error`), creates the channel (or reconnects the
        existing one for the same account) and redirects the browser to the dashboard's
        `/channels/callback/{provider}` page with `?channel=<id>`, or `?error=<message>`.
      parameters:
        - { name: code, in: query, schema: { type: string } }
        - { name: state, in: query, schema: { type: string } }
        - { name: error, in: query, schema: { type: string } }
        - { name: error_description, in: query, schema: { type: string } }
      responses:
        "302":
          description: "Redirect to the dashboard's callback page (?channel=<id> or ?error=<message>)"
          headers: { Location: { schema: { type: string } } }
          content: { text/html: { schema: { type: string } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/channel-links:
    get:
      operationId: listChannelLinks
      tags: [channels]
      summary: The workspace's connect links, newest first (last 100); tokens are never shown again (channels:manage)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/ChannelLink" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createChannelLink
      tags: [channels]
      summary: A link someone without access to Staqly opens to connect a channel to this workspace (channels:manage)
      description: |
        The answer carries `url` and `token` once; the token is in the URL fragment. The page shows the
        workspace's name, `note` and the platforms in `providers` (empty: every platform this server can
        connect; your own automation is never offered). Connected channels join `group_id` (empty: Default).
        A link works for `expires_in_hours` (default 168, at most 720) and `max_uses` channels (default 1, at
        most 50); revoke it with DELETE. The plan's channel limit applies when someone connects.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note: { type: string, maxLength: 300, description: Shown to the person who opens the link }
                providers: { type: array, items: { type: string }, description: Platform ids to offer; empty offers every available one }
                group_id: { type: string, description: The channel group connected channels join (empty is Default) }
                expires_in_hours: { type: integer, minimum: 0, maximum: 720, description: "0 is 7 days" }
                max_uses: { type: integer, minimum: 0, maximum: 50, description: "How many channels may be connected; 0 is 1" }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/ChannelLink" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/channel-links/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    delete:
      operationId: revokeChannelLink
      tags: [channels]
      summary: Stop a connect link from working; channels already connected stay (channels:manage)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/connect-links/{token}:
    parameters:
      - { name: token, in: path, required: true, description: The connect link's token, schema: { type: string } }
    get:
      operationId: openConnectLink
      tags: [channels]
      security: []
      summary: What a connect link's page shows (no credentials; the token is the key; 60 per 10 minutes per address)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ChannelLinkPublic" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "410": { description: "The link was revoked, expired or used up (link_revoked, link_expired, link_used)", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/connect-links/{token}/channels:
    parameters:
      - { name: token, in: path, required: true, description: The connect link's token, schema: { type: string } }
    post:
      operationId: connectChannelViaLink
      tags: [channels]
      security: []
      summary: Connect a platform that signs in with a token, through a connect link (no credentials, no session)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider]
              properties:
                provider: { type: string }
                fields: { type: object, additionalProperties: { type: string }, description: The platform's credential fields }
      responses:
        "201":
          description: Connected
          content: { application/json: { schema: { type: object, required: [name, handle, provider], properties: { name: { type: string }, handle: { type: string }, provider: { type: string } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "410": { description: "The link was revoked, expired or used up", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/connect-links/{token}/oauth/{provider}/start:
    parameters:
      - { name: token, in: path, required: true, description: The connect link's token, schema: { type: string } }
      - { name: provider, in: path, required: true, schema: { type: string } }
    get:
      operationId: startChannelOAuthViaLink
      tags: [channels]
      security: []
      summary: Begin connecting an OAuth platform through a connect link (no credentials)
      description: |
        Like startChannelOAuth, but the signed state names the link instead of a session, so the callback
        connects the account in the link's workspace without anyone signed in. The state and the link are
        pinned to this browser with short-lived cookies.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [url], properties: { url: { type: string, format: uri } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "410": { description: "The link was revoked, expired or used up", content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/connect-links/oauth/{provider}/callback:
    parameters: [{ name: provider, in: path, required: true, schema: { type: string } }]
    get:
      operationId: finishChannelOAuthViaLink
      tags: [channels]
      security: []
      summary: Finish connecting an OAuth platform started from a connect link (no credentials)
      description: |
        The dashboard's `/channels/callback/{provider}` page forwards the platform's answer here when the
        state belongs to a link. Redirects to the dashboard's `/add-channel` page with
        `?connected=<handle>&workspace=<name>` or `?error=<message>`, the link's token in the fragment.
      parameters:
        - { name: code, in: query, schema: { type: string } }
        - { name: state, in: query, schema: { type: string } }
        - { name: error, in: query, schema: { type: string } }
        - { name: error_description, in: query, schema: { type: string } }
      responses:
        "302":
          description: "Redirect to the dashboard's /add-channel page"
          headers: { Location: { schema: { type: string } } }
          content: { text/html: { schema: { type: string } } }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/channels/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getChannel
      tags: [channels]
      summary: Get a channel (channels:read); credentials are never returned
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Channel" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateChannel
      tags: [channels]
      summary: Rename, change defaults, turn off or on, or reconnect with fresh fields (channels:manage)
      description: |
        `status` is `connected` or `disabled` (a disabled channel's targets fail without trying).
        `fields` reconnects a token platform with new credentials, checked against the platform first;
        OAuth platforms reconnect through the OAuth flow.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ChannelPatch" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Channel" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteChannel
      tags: [channels]
      summary: Remove a channel; targets on it go with it (channels:manage)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/channel-groups:
    get:
      operationId: listChannelGroups
      tags: [channels]
      summary: List channel groups with their channel counts (channels:read)
      description: The Default group comes first and always exists.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/ChannelGroup" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createChannelGroup
      tags: [channels]
      summary: Create a channel group (channels:manage)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ChannelGroupInput" } } } }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/ChannelGroup" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/channel-groups/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    patch:
      operationId: updateChannelGroup
      tags: [channels]
      summary: Rename a channel group or change its colour or position (channels:manage)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ChannelGroupInput" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ChannelGroup" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteChannelGroup
      tags: [channels]
      summary: Delete a channel group; its channels move to Default (channels:manage)
      description: The Default group can't be deleted (409 `default_group`).
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/channels/{id}/test:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: testChannel
      tags: [channels]
      summary: Check the stored credentials against the platform (channels:manage)
      description: Rate-limited per principal (burst 5, then 10 per minute). A failing check marks the channel `needs_reconnect`; a passing one clears it.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200":
          description: Result of the check (platform errors are reported, not raised)
          content: { application/json: { schema: { type: object, required: [ok], properties: { ok: { type: boolean }, error: { type: string }, profile: { type: object, additionalProperties: true, description: "What the platform knows the account as (handle, name, avatar_url, profile_url)" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/schedules:
    get:
      operationId: listSchedules
      tags: [schedules]
      summary: List publishing schedules (posts:read)
      parameters:
        - { name: collection_id, in: query, description: A collection id or exact name, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Schedule" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createSchedule
      tags: [schedules]
      summary: Create a publishing schedule (schedules:manage)
      description: |
        `kind` once (`run_at` in the future) or recurring (`rule`: `times` with optional `weekdays` in
        `tz`, or `every_minutes`). At each run the schedule picks posts of its collection (`pick`:
        `next`, the oldest waiting draft; `selected`, the `post_ids`; `all`) and publishes them to its
        `channels` (a post's own targets win). When nothing waits, `source` decides: `none`, `agent`
        (emit `schedule.posts_requested` and list it on GET /v1/work) or `endpoint` (POST to
        `endpoint_url`, signed like a webhook with `endpoint_secret`, which answers `{posts: [...]}`).
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/ScheduleInput" }, { required: [collection, kind] }] } } } }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Schedule" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/schedules/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getSchedule
      tags: [schedules]
      summary: Get a schedule (posts:read)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Schedule" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateSchedule
      tags: [schedules]
      summary: Change a schedule (schedules:manage); the next run is recomputed
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ScheduleInput" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Schedule" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteSchedule
      tags: [schedules]
      summary: Delete a schedule (schedules:manage)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/schedules/{id}/run:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: runSchedule
      tags: [schedules]
      summary: Run a schedule now; its regular next run is unchanged (schedules:manage)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "202": { description: Queued, content: { application/json: { schema: { $ref: "#/components/schemas/Schedule" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/jobs:
    get:
      operationId: listJobs
      tags: [jobs]
      summary: The background queue, newest first (posts:read)
      description: Publish runs (one job per post and one per target), target timeouts, schedule runs, replies, reposts and token refreshes. Done, failed and cancelled jobs are kept for 7 days.
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [queued, running, done, failed, cancelled] } }
        - { name: kind, in: query, schema: { type: string, enum: [publish_post, publish_target, target_timeout, run_schedule, refresh_channel, repeat_post, publish_reply, repost, notify_people, notify_person] } }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/JobPage" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/jobs/stats:
    get:
      operationId: jobStats
      tags: [jobs]
      summary: Queue counts (posts:read)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/JobStats" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/jobs/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getJob
      tags: [jobs]
      summary: Get a job (posts:read)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Job" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/jobs/{id}/retry:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: retryJob
      tags: [jobs]
      summary: Queue a failed job again, now (posts:publish)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "202": { description: Queued, content: { application/json: { schema: { $ref: "#/components/schemas/Job" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/jobs/{id}/cancel:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: cancelJob
      tags: [jobs]
      summary: Cancel a queued job (posts:publish)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Job" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/agents:
    get:
      operationId: listAgents
      tags: [auth]
      summary: Agents seen talking to the workspace, most recently active first
      description: >-
        Recorded automatically: MCP clients by their clientInfo (Claude Code, Codex, Cursor...), REST clients by
        their User-Agent (an SDK's appended user agent names the agent), per credential. Browsers are not agents.
        Needs admin: it names the API keys agents use. At most 200 agents are kept per workspace.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Agent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/agents/{id}:
    delete:
      operationId: forgetAgent
      tags: [auth]
      summary: Remove an agent from the list (it comes back when it talks to the workspace again)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: Removed }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media:
    get:
      operationId: listMedia
      tags: [media]
      summary: The media library (media:read), newest first by default
      parameters:
        - { name: q, in: query, schema: { type: string }, description: Part of the file name (case-insensitive) }
        - { name: kind, in: query, schema: { type: string, enum: [image, video, audio] } }
        - { name: used, in: query, schema: { type: string, enum: [used, unused] }, description: "used (posts carry it) or unused" }
        - { name: folder, in: query, schema: { type: string }, description: "A folder id (files directly in it) or root (the top level); omit for every folder" }
        - { name: tag, in: query, schema: { type: string }, description: "Files carrying this tag (normalised like on save: lowercase, no #)" }
        - { name: deleted, in: query, schema: { type: boolean, default: false }, description: "true lists the files in Deleted (each with deleted_at and purge_at) instead of the library" }
        - { name: duplicates, in: query, schema: { type: boolean, default: false }, description: "true lists only files with an identical twin (same sha256), grouped by content, oldest first" }
        - { name: sort, in: query, schema: { type: string, enum: [created_desc, created_asc, size_desc, name_asc], default: created_desc } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 60 } }
      responses:
        "200": { description: One page, content: { application/json: { schema: { $ref: "#/components/schemas/MediaPage" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: uploadMedia
      tags: [media]
      summary: Upload a file (media:write). Type is detected from content; SVG/HTML are rejected.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } }
      responses:
        "201": { description: Uploaded, content: { application/json: { schema: { $ref: "#/components/schemas/Media" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media/delete:
    post:
      operationId: deleteMediaMany
      tags: [media]
      summary: Move several files to Deleted, or delete them for good with permanent (posts:delete)
      description: "Moving to Deleted is refused (409) when a scheduled post uses one of the files; nothing moves then. With `permanent`, posts referencing the files lose them."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids: { type: array, minItems: 1, maxItems: 200, items: { type: string } }
                permanent: { type: boolean, default: false, description: "Delete for good now instead of moving to Deleted" }
      responses:
        "200":
          description: What was deleted and which ids were not found
          content:
            application/json:
              schema:
                type: object
                required: [deleted, missing]
                properties:
                  deleted: { type: array, items: { type: string } }
                  missing: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media/bulk:
    post:
      operationId: bulkUpdateMedia
      tags: [media]
      summary: Move several files to a folder and/or add and remove tags (media:write)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids: { type: array, minItems: 1, maxItems: 200, items: { type: string } }
                folder_id: { type: string, description: "A folder id, or empty for the top level; omit to leave folders alone" }
                add_tags: { type: array, items: { type: string } }
                remove_tags: { type: array, items: { type: string } }
      responses:
        "200":
          description: Which files changed and which ids were not found
          content:
            application/json:
              schema:
                type: object
                required: [updated, missing]
                properties:
                  updated: { type: array, items: { type: string } }
                  missing: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media-folders:
    get:
      operationId: listMediaFolders
      tags: [media]
      summary: The library's folders, file counts and tags (media:read)
      responses:
        "200": { description: The tree, content: { application/json: { schema: { $ref: "#/components/schemas/MediaFolders" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createMediaFolder
      tags: [media]
      summary: Create a folder, at the top level or inside another (media:write)
      description: Names are unique among a folder's subfolders, ignoring case (409). Folders nest at most 10 deep.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
                parent_id: { type: string, description: A folder id; empty or omitted for the top level }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/MediaFolder" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media-folders/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    patch:
      operationId: updateMediaFolder
      tags: [media]
      summary: Rename a folder or move it under another one (media:write)
      description: A folder can't move inside itself or one of its own folders (422).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
                parent_id: { type: string, description: "A folder id, or empty for the top level" }
      responses:
        "200": { description: The folder, content: { application/json: { schema: { $ref: "#/components/schemas/MediaFolder" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteMediaFolder
      tags: [media]
      summary: Delete a folder (media:write); its files and subfolders move up to its parent, no file is deleted
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media/{id}/info:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getMediaInfo
      tags: [media]
      summary: A file's details (media:read); the file itself is GET /v1/media/{id}
      responses:
        "200": { description: The file, content: { application/json: { schema: { $ref: "#/components/schemas/Media" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media/{id}/restore:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: restoreMedia
      tags: [media]
      summary: Take a file out of Deleted, back where it was (posts:delete)
      responses:
        "200": { description: The file, content: { application/json: { schema: { $ref: "#/components/schemas/Media" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media/{id}/keep:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: keepMedia
      tags: [media]
      summary: Keep this file of a set of identical ones (posts:delete and posts:write)
      description: Posts that use a copy (in their files or a comment) use this file instead, in one transaction, and the copies move to Deleted.
      responses:
        "200":
          description: The copies moved to Deleted
          content: { application/json: { schema: { type: object, required: [deleted], properties: { deleted: { type: array, items: { type: string } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media/{id}/replace:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: replaceMedia
      tags: [media]
      summary: Replace a file with its edit (media:write, posts:write, posts:delete)
      description: >
        The media editor's "Replace the original". A media id never changes content, so the edit is a new file that
        takes the original's name, alt text, folder and tags; drafts that used the original use it instead, and the
        original moves to Deleted (restorable for 30 days). A file that a scheduled, publishing, published or
        failed post uses answers 409: those posts keep what they had, save the edit as a new file instead.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } }
      responses:
        "201": { description: The edited file, content: { application/json: { schema: { $ref: "#/components/schemas/Media" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media-projects:
    get:
      operationId: listMediaProjects
      tags: [media]
      summary: The media editor's projects, newest first (media:read)
      parameters:
        - { name: media, in: query, schema: { type: string }, description: "Only projects made from this file or saved as it" }
      responses:
        "200":
          description: Projects
          content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/MediaProject" } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createMediaProject
      tags: [media]
      summary: Save an editable project of the media editor (media:write)
      description: The file is the editor's .gpex project (layers, text, masks). Projects count toward the workspace's storage.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: "#/components/schemas/MediaProjectUpload" }
      responses:
        "201": { description: The project, content: { application/json: { schema: { $ref: "#/components/schemas/MediaProject" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media-projects/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getMediaProject
      tags: [media]
      summary: A project's facts (media:read)
      responses:
        "200": { description: The project, content: { application/json: { schema: { $ref: "#/components/schemas/MediaProject" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      operationId: updateMediaProject
      tags: [media]
      summary: Save a new version of a project (media:write)
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: "#/components/schemas/MediaProjectUpload" }
      responses:
        "200": { description: The project, content: { application/json: { schema: { $ref: "#/components/schemas/MediaProject" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteMediaProject
      tags: [media]
      summary: Delete a project and its file; the files it was made from stay (posts:delete)
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media-projects/{id}/file:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getMediaProjectFile
      tags: [media]
      summary: Download a project's .gpex file (media:read)
      responses:
        "200": { description: The project file, content: { application/octet-stream: { schema: { type: string, format: binary } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/ai:
    get:
      operationId: getAI
      tags: [media]
      summary: Whether the media editor's AI tools work, with which model (media:read)
      description: >
        The AI tools run on the workspace's own OpenAI key; staqly never pays for these calls. The key is never
        returned, only its last four characters.
      responses:
        "200": { description: AI status, content: { application/json: { schema: { $ref: "#/components/schemas/AIStatus" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      operationId: setAI
      tags: [media]
      summary: Set the workspace's OpenAI key and image model (admin)
      description: The key is checked with OpenAI (listing the model, which costs nothing), then stored encrypted. Without api_key, only the model changes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                provider: { type: string, enum: [openai] }
                model: { type: string, enum: [gpt-image-1, gpt-image-1-mini, dall-e-3, dall-e-2], description: Defaults to gpt-image-1 }
                api_key: { type: string, description: "An OpenAI API key (sk-...)" }
      responses:
        "200": { description: AI status, content: { application/json: { schema: { $ref: "#/components/schemas/AIStatus" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/BadGateway" }
    delete:
      operationId: deleteAI
      tags: [media]
      summary: Forget the workspace's AI key (admin)
      responses:
        "204": { description: Forgotten }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/ai/images/{op}:
    parameters:
      - { name: op, in: path, required: true, schema: { type: string, enum: [generate, edit] } }
    post:
      operationId: aiImage
      tags: [media]
      summary: Make a picture with the workspace's AI key (media:write)
      description: >
        generate makes a picture from a prompt; edit changes the image by the prompt, only inside the mask's
        transparent pixels when a mask is sent. Answers the picture; nothing is stored. At most 30 pictures per 10
        minutes per workspace; the provider's own limits and refusals come back as 429 or 422.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [prompt]
              properties:
                prompt: { type: string, maxLength: 4000 }
                size: { type: string, description: "The model's sizes, e.g. 1024x1024, 1536x1024, 1024x1536 (gpt-image-1)" }
                image: { type: string, format: binary, description: Required for edit }
                mask: { type: string, format: binary, description: "PNG; transparent pixels mark what may change" }
      responses:
        "200": { description: The picture, content: { image/png: { schema: { type: string, format: binary } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/BadGateway" }
  /v1/media/{id}/usage:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getMediaUsage
      tags: [media]
      summary: Posts that use a file (media:read; empty without posts:read), newest first, at most 50
      responses:
        "200":
          description: The posts
          content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/MediaUse" } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/media/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getMedia
      tags: [media]
      summary: Download/preview a file (media:read). Supports Range requests.
      description: >
        With `w`, returns a thumbnail at most `w` pixels wide instead of the
        original (PNG, JPEG, GIF first frame, WebP). Thumbnails are generated
        on first request and cached on disk; JPEG for opaque images, PNG when
        there is transparency; EXIF orientation is applied. Images are never
        upscaled: when the original is not wider than `w`, it is returned
        as is, as it is for AVIF and for images over 50 megapixels (not
        decoded server-side). Thumbnail responses carry an `ETag` and
        `Cache-Control: private, max-age=31536000, immutable` (a media id
        never changes content). Other file types answer 422 when `w` is set.
      parameters:
        - { name: download, in: query, schema: { type: string }, description: Force attachment disposition }
        - { name: w, in: query, schema: { type: integer, enum: [160, 320, 640, 1280] }, description: "Thumbnail width in pixels (images only)" }
      responses:
        "200": { description: "File bytes (or the thumbnail), with the stored MIME type", content: { "*/*": { schema: { type: string, format: binary } } } }
        "206": { description: Partial content (Range request), content: { "*/*": { schema: { type: string, format: binary } } } }
        "304": { description: Not modified (If-None-Match / If-Modified-Since) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "416": { description: Range not satisfiable }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateMedia
      tags: [media]
      summary: Rename a file, set its alt text, move it to a folder, set its tags, or report a video's size and length (media:write)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1 }
                alt_text: { type: string, maxLength: 1500 }
                width: { type: integer, minimum: 0, maximum: 100000 }
                height: { type: integer, minimum: 0, maximum: 100000 }
                duration_ms: { type: integer, minimum: 0, maximum: 86400000 }
                folder_id: { type: string, description: "Moves the file: a folder id, or empty for the top level" }
                tags: { type: array, maxItems: 20, items: { type: string, maxLength: 40 }, description: "Replaces the file's tags; lowercased, without #, spaces become dashes" }
      responses:
        "200": { description: The file, content: { application/json: { schema: { $ref: "#/components/schemas/Media" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteMedia
      tags: [media]
      summary: Move a file to Deleted (restorable for 30 days), or delete it for good with permanent=true (posts:delete)
      description: >
        Requires `posts:delete`; `media:write` alone answers 403. A file in Deleted is still served and still
        counts toward storage; the retention sweep deletes it for good 30 days later (`purge_at`). Moving a
        file that a scheduled or publishing post uses answers 409. With `permanent=true` the file is deleted
        now and posts referencing it lose it.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
        - { name: permanent, in: query, schema: { type: boolean, default: false } }
      responses:
        "204": { description: Moved to Deleted (or deleted for good) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/events:
    get:
      operationId: listEvents
      tags: [events]
      summary: Workspace activity log, newest first (events:read)
      description: >
        Credential and session audit events (api_key.*, session.*,
        recovery_key.*, device.*, device_link.*, sign_in_method.*) are only listed for admin
        principals (dashboard sessions, the Community Edition's access token, keys with the admin scope).
      parameters:
        - { name: post_id, in: query, schema: { type: string } }
        - { name: collection_id, in: query, schema: { type: string } }
        - { name: channel_id, in: query, schema: { type: string } }
        - { name: schedule_id, in: query, schema: { type: string } }
        - { name: type, in: query, schema: { type: string } }
        - { name: actor_type, in: query, schema: { type: string, enum: [local, session, api_key, system] } }
        - { name: actor_id, in: query, description: "One actor, e.g. an API key id (key_...) or a session id", schema: { type: string } }
        - { name: agent, in: query, description: "Agent name as listed by /v1/agents (e.g. claude-code, n8n); repeat for any of several", schema: { type: array, items: { type: string } }, style: form, explode: true }
        - { name: type_prefix, in: query, description: "Literal type prefix, e.g. `post.` or `webhook.`", schema: { type: string } }
        - { name: from, in: query, description: "created_at >= (RFC3339 or YYYY-MM-DD)", schema: { type: string } }
        - { name: to, in: query, description: "created_at < (RFC3339 or YYYY-MM-DD)", schema: { type: string } }
        - { name: since, in: query, description: Alias of from, schema: { type: string } }
        - { name: until, in: query, description: Alias of to, schema: { type: string } }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/eventCursor" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/EventPage" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhooks:
    get:
      operationId: listWebhooks
      tags: [webhooks]
      summary: List webhooks (webhooks:manage)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Webhook" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createWebhook
      tags: [webhooks]
      summary: Create a webhook. A signing secret is generated.
      description: The response is the only one that shows the signing secret in full (besides rotate_secret); store it now.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/WebhookInput" }, { required: [url] }] } } } }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Webhook" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/webhooks/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getWebhook
      tags: [webhooks]
      summary: Get a webhook
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Webhook" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateWebhook
      tags: [webhooks]
      summary: Update a webhook (set rotate_secret to issue a new secret)
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/WebhookInput" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Webhook" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteWebhook
      tags: [webhooks]
      summary: Delete a webhook
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/webhooks/{id}/test:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: testWebhook
      tags: [webhooks]
      summary: Queue a webhook.test delivery
      description: Rate-limited per principal (burst 5, then 10 per minute).
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "202": { description: Queued, content: { application/json: { schema: { $ref: "#/components/schemas/Delivery" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/webhook-deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [webhooks]
      summary: Delivery history
      parameters:
        - { name: webhook_id, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [pending, succeeded, failed] } }
        - { name: post_id, in: query, description: Only deliveries of events about this post, schema: { type: string } }
        - { $ref: "#/components/parameters/limit" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Delivery" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/webhook-deliveries/{id}/retry:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: retryWebhookDelivery
      tags: [webhooks]
      summary: Re-queue a finished delivery
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "202": { description: Queued, content: { application/json: { schema: { $ref: "#/components/schemas/Delivery" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/events/export:
    get:
      operationId: exportEvents
      tags: [events]
      summary: Export the activity log as NDJSON or CSV, oldest first (events:read)
      description: |
        Streams every matching event (chunked; no page size). CSV columns:
        `created_at,id,type,actor_type,actor_id,actor_name,collection_id,post_id,data`
        where `data` is the event's JSON. CSV cells starting with `=`, `+`, `-`,
        `@`, tab or CR are prefixed with `'` (spreadsheet formula injection).
      parameters:
        - { name: format, in: query, schema: { type: string, enum: [ndjson, csv], default: ndjson } }
        - { name: from, in: query, description: "created_at >= (RFC3339 or YYYY-MM-DD)", schema: { type: string } }
        - { name: to, in: query, description: "created_at < (RFC3339 or YYYY-MM-DD)", schema: { type: string } }
        - { name: type, in: query, description: Exact event type, schema: { type: string } }
        - { name: type_prefix, in: query, description: "Literal prefix, e.g. `post.`", schema: { type: string } }
        - { name: actor_type, in: query, schema: { type: string, enum: [session, api_key, local, system] } }
        - { name: actor_id, in: query, schema: { type: string } }
        - { name: collection_id, in: query, schema: { type: string } }
        - { name: post_id, in: query, schema: { type: string } }
      responses:
        "200":
          description: Event stream (attachment)
          content:
            application/x-ndjson:
              schema: { $ref: "#/components/schemas/Event" }
            text/csv:
              schema: { type: string }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/workspace/export:
    post:
      operationId: createWorkspaceExport
      tags: [auth]
      summary: Start a workspace export (admin; GDPR data portability)
      description: |
        Queues a background job that builds a ZIP (workspace.json,
        collections.ndjson, posts.ndjson with their targets, events.ndjson,
        media.ndjson, media/ files under their original names, webhooks.json
        with secrets redacted, channels.json without credentials, schedules.json, README.txt).
        Every plan can export. On a plan without data export (Starter) the
        export is data only (`includes_media: false`): the same files without
        media/, media.ndjson lists each file by id, original_name and
        mime_type. Plus and above, the trial and the Community Edition get the files too.
        Only one export per workspace may be pending or running (409 with
        `details.export_id`). Finished archives are deleted after 24 hours.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "202": { description: Accepted, content: { application/json: { schema: { $ref: "#/components/schemas/WorkspaceExport" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/workspace/exports:
    get:
      operationId: listWorkspaceExports
      tags: [auth]
      summary: The workspace's latest exports (backups), newest first, at most 20 (admin)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/WorkspaceExport" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/workspace/exports/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getWorkspaceExport
      tags: [auth]
      summary: Export job status (admin)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/WorkspaceExport" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/workspace/exports/{id}/download:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: downloadWorkspaceExport
      tags: [auth]
      summary: Download a finished export (admin)
      responses:
        "200": { description: ZIP archive (attachment), content: { application/zip: { schema: { type: string, contentMediaType: application/zip } } } }
        "206": { description: Partial content (Range request), content: { application/zip: { schema: { type: string, contentMediaType: application/zip } } } }
        "304": { description: Not modified (If-Modified-Since) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "410": { $ref: "#/components/responses/Gone" }
        "416": { description: Range not satisfiable }
        "429": { $ref: "#/components/responses/RateLimited" }

  /livez:
    get:
      operationId: livez
      tags: [system]
      summary: Liveness probe
      description: 200 while the process can serve HTTP. Does not touch the database.
      security: []
      responses:
        "200":
          description: Alive
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string, const: ok }

  /readyz:
    get:
      operationId: readyz
      tags: [system]
      summary: Readiness probe
      description: |
        Checks the database (ping), that every embedded migration is applied,
        and that the background workers are running (heartbeat newer than 30s).
        `workers` is `not_started` (not a failure) when the process runs no
        background workers.
      security: []
      responses:
        "200":
          description: Ready
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Readiness" }
        "503":
          description: Not ready
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Readiness" }

  /metrics:
    get:
      operationId: metrics
      tags: [system]
      summary: Prometheus metrics (text exposition format 0.0.4)
      description: |
        Allowed for direct requests from this machine (loopback peer, local
        Host, no proxy/Origin headers) or with `Authorization: Bearer
        <METRICS_TOKEN>`. Everything else gets 404.
      security:
        - {}
        - metricsToken: []
      responses:
        "200":
          description: Metrics
          content:
            text/plain:
              schema: { type: string }
        "404":
          description: Not allowed (endpoint hidden)

  /v1/alerts:
    get:
      operationId: listAlerts
      tags: [alerts]
      summary: List alert channels (alerts:manage)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/Alert" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createAlert
      tags: [alerts]
      summary: Create an alert channel (alerts:manage)
      description: |
        Telegram, Slack, Discord, ntfy or email. Secrets are stored encrypted and returned masked.
        Default events: `post.published`, `post.failed`, `channel.needs_reconnect`. Messages carry a
        dashboard link to the post and, for `post.target_published`, the public URL.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/AlertInput" }, { required: [name, type, config] }] } } } }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Alert" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/alerts/{id}:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: getAlert
      tags: [alerts]
      summary: Get an alert channel
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Alert" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: updateAlert
      tags: [alerts]
      summary: Update an alert channel
      description: |
        `config` keys are merged into the stored config; `null` removes a key. Sending a masked
        secret back keeps the stored one. Changing where messages go (a Telegram chat_id, say)
        requires the secret again (422). Re-enabling resets the failure count.
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/AlertInput" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Alert" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      operationId: deleteAlert
      tags: [alerts]
      summary: Delete an alert channel
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/alerts/{id}/test:
    parameters: [{ $ref: "#/components/parameters/id" }]
    post:
      operationId: testAlert
      tags: [alerts]
      summary: Send a test message now
      description: Rate-limited per principal (burst 5, then 10 per minute).
      parameters:
        - { $ref: "#/components/parameters/idempotencyKey" }
      responses:
        "200":
          description: Result of the attempt (delivery errors are reported, not raised)
          content: { application/json: { schema: { type: object, required: [ok], properties: { ok: { type: boolean }, error: { type: string } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/alerts/{id}/deliveries:
    parameters: [{ $ref: "#/components/parameters/id" }]
    get:
      operationId: listAlertDeliveries
      tags: [alerts]
      summary: Recent outbox rows of an alert channel
      parameters:
        - { $ref: "#/components/parameters/limit" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, required: [data], properties: { data: { type: array, items: { $ref: "#/components/schemas/AlertDelivery" } } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /mcp:
    post:
      operationId: mcp
      tags: [setup]
      summary: MCP Streamable HTTP endpoint (stateless; bearer access token in the Community Edition, bearer API key in cloud mode)
      responses:
        "200": { description: JSON-RPC response }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearer: { type: http, scheme: bearer, description: "API key (stk_...), or in the Community Edition the access token (stk_local_...)" }
    session: { type: apiKey, in: cookie, name: staqly_session }

    metricsToken:
      type: http
      scheme: bearer
      description: The METRICS_TOKEN environment value (not an API key).

  parameters:
    id: { name: id, in: path, required: true, schema: { type: string } }
    limit: { name: limit, in: query, description: "Page size (default 50, at most 200)", schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
    cursor: { name: cursor, in: query, description: Opaque keyset cursor (the previous page's next_cursor), schema: { type: string } }
    eventCursor: { name: cursor, in: query, description: "The previous page's next_cursor (an event seq)", schema: { type: string, pattern: "^[0-9]+$" } }
    postStatus: { name: status, in: query, schema: { $ref: "#/components/schemas/PostStatus" } }
    channelId: { name: channel_id, in: query, description: Posts with a target on this channel, schema: { type: string } }
    tag: { name: tag, in: query, description: Posts carrying this tag, schema: { type: string, example: "launch" } }
    q: { name: q, in: query, description: "Words of the content (case-insensitive substring match)", schema: { type: string } }
    publishAfter: { name: publish_after, in: query, description: "publish_at >= (RFC3339 or YYYY-MM-DD); posts without a time never match", schema: { type: string } }
    publishBefore: { name: publish_before, in: query, description: "publish_at < (RFC3339 or YYYY-MM-DD)", schema: { type: string } }
    postSort: { name: sort, in: query, description: "Default publish_asc (soonest first, posts without a time last). created_* and updated_desc order by those times.", schema: { type: string, enum: [publish_asc, publish_desc, created_desc, created_asc, updated_desc] } }
    idempotencyKey:
      name: Idempotency-Key
      in: header
      description: |
        Replays the stored response when the same key is repeated with the same method, path,
        query string and body within 24 hours (422 `idempotency_mismatch` with a different
        request, 409 `idempotency_in_progress` while the first one runs). Not applied to
        multipart uploads or to responses that carry a secret.
      schema: { type: string, maxLength: 200 }

  responses:
    BadRequest:
      description: The body is not valid JSON or has unknown fields (`bad_request`)
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
    Unauthorized:
      description: |
        Missing, invalid, expired or revoked credentials (`unauthorized`). In cloud mode every
        /v1 request except the public ones needs an API key or a session; a malformed or
        unknown `Authorization` header is refused on every /v1 endpoint.
      headers:
        WWW-Authenticate: { schema: { type: string }, description: "Bearer realm=\"staqly\"" }
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
    PaymentRequired:
      description: |
        `plan_limit_exceeded` (a plan limit; `details` has limit, max, used, plan),
        `plan_expired` (the workspace is suspended and read-only), `payment_required`, or `x_posts_exhausted` (this
        period's posts to X, included and bought, can't cover the post; `details` has needed, left, period_end).
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
    Forbidden:
      description: |
        `forbidden` (the API key lacks a scope, or the action needs a dashboard session) or
        `csrf_failed` (session request without a valid `X-CSRF-Token`).
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
    NotFound:
      description: Not found in this workspace (`not_found`)
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
    Conflict:
      description: |
        `conflict` (state or version conflict, including re-applying the current state,
        e.g. "collection is already archived"), or `idempotency_in_progress` when a request
        with the same `Idempotency-Key` is still running.
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
    Gone:
      description: Used, expired or deleted (`gone`, `link_used`, `link_expired`)
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
    PayloadTooLarge:
      description: Body over the size limit (`too_large`; JSON bodies 2 MiB, uploads MAX_UPLOAD_BYTES)
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
    UnsupportedMediaType:
      description: |
        A file type that may not be uploaded (`unsupported_media_type`). A request body that
        is neither JSON nor multipart/form-data also gets 415 with the same error code.
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
    ValidationFailed:
      description: |
        `validation_failed` (`fields` maps each invalid field to a message), or
        `idempotency_mismatch` when an `Idempotency-Key` is reused with a different request.
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
    RateLimited:
      description: |
        Too many requests (`error.code` = `rate_limited`). Unauthenticated
        sensitive endpoints (POST /v1/sessions/recover, POST
        /v1/sessions/api-key, POST /v1/device-links/redeem, POST /v1/workspaces) are
        limited per client IP; other /v1 and /mcp requests in cloud mode are
        limited per API key / session.
      headers:
        Retry-After: { $ref: "#/components/headers/Retry-After" }
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
    BadGateway:
      description: The payment provider (Stripe) refused or failed the request (`payment_provider_error`)
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
  schemas:
    Agent:
      type: object
      required: [id, kind, name, version, principal_id, principal_name, first_seen_at, last_seen_at, requests, posts_created]
      properties:
        id: { type: string }
        kind: { type: string, enum: [mcp, api] }
        name: { type: string, description: "e.g. claude-code, my-agent, n8n" }
        version: { type: string }
        principal_id: { type: string, description: "The API key it uses, or local for the local access token" }
        principal_name: { type: string }
        first_seen_at: { type: string, format: date-time }
        last_seen_at: { type: string, format: date-time }
        requests: { type: integer, description: "Requests recorded (written at most once a minute)" }
        posts_created: { type: integer }
    Usage:
      description: Plan, monthly usage and trial/suspension state (also returned inline by GET /v1/workspace/usage)
      type: object
      required: [plan, period, usage, plan_expires_at, trial_ends_at, suspended, posts_by_status, webhook_deliveries]
      properties:
        plan: { $ref: "#/components/schemas/Plan" }
        period: { type: string, examples: ["2026-10"] }
        usage:
          type: object
          required: [posts_per_month, channels, seats, alerts, webhooks, api_keys, storage_bytes, x_posts_per_period]
          properties:
            posts_per_month: { type: integer, description: Posts created this month }
            channels: { type: integer, description: Connected channels }
            seats: { type: integer, description: "Seats billed: the workspace members (each billed per user)" }
            alerts: { type: integer }
            webhooks: { type: integer }
            api_keys: { type: integer }
            storage_bytes: { type: integer }
            x_posts_per_period: { type: integer, description: "Posts to X this month (a post with a live link counts 14)" }
        plan_expires_at:
          type: [string, "null"]
          format: date-time
          description: |
            End of the free trial or paid period; null = no end. A Stripe-billed plan adds a 3-day payment grace
            after its paid or trial period, so count a trial down to `trial_ends_at` instead.
        trial_ends_at:
          type: [string, "null"]
          format: date-time
          description: |
            When the 7-day free trial ends: Stripe's `trial_end` for a Stripe trial, the end of the server-side
            trial on a server without billing; null when the workspace is in no trial.
        suspended:
          type: boolean
          description: |
            The trial or paid period ended unpaid. The workspace is read-only:
            reads and exports work, other changes answer 402 `plan_expired` until it is
            a plan is paid for (`POST /v1/billing/checkout`).
        posts_by_status:
          type: object
          description: Posts created this period, counted by their current status (statuses with no posts are left out).
          additionalProperties: { type: integer }
          examples: [{ draft: 2, scheduled: 2, published: 1 }]
        webhook_deliveries:
          type: object
          description: Webhook deliveries right now. `failed` gave up after every retry and can be redelivered.
          required: [queued, retrying, failed]
          properties:
            queued: { type: integer, description: Waiting for their first attempt }
            retrying: { type: integer, description: "Failed at least once; another attempt is scheduled" }
            failed: { type: integer, description: Gave up after every retry }
    Billing:
      type: object
      required: [enabled, plan, subscription_status, plan_expires_at, trial_ends_at, suspended, has_customer, seats, trial]
      properties:
        enabled: { type: boolean, description: Billing is configured on this instance }
        plan: { type: string, description: "starter, plus, pro or business (cloud; an operator may also set a plan of their own), unlocked (Community Edition)", examples: [plus] }
        subscription_status:
          type: [string, "null"]
          description: Stripe's status (active, trialing, past_due, canceled, unpaid, incomplete); null when never subscribed
          examples: [active]
        plan_expires_at: { type: [string, "null"], description: "End of the trial or paid period, plus a 3-day payment grace when Stripe bills it (null = no end)" }
        trial_ends_at: { type: [string, "null"], format: date-time, description: "When the free trial ends (Stripe's trial_end, or the server-side trial's end without billing); null when not in a trial" }
        suspended: { type: boolean, description: The period ended unpaid; the workspace is read-only }
        has_customer: { type: boolean, description: A Stripe customer exists (the portal is available) }
        seats: { type: integer, description: "People billed: every workspace member, whatever the role (plans are priced per user). Open invitations are billed once accepted; the subscription's quantity follows the members", examples: [3] }
        trial:
          type: string
          enum: [available, used, other_workspace, none]
          description: "Whether choosing Starter now starts its 7-day trial (card required): available; used (this workspace had a subscription before); other_workspace (another workspace of the account is in its trial, so this one is charged today); none (billing off, or the workspace already pays). Each workspace gets the trial once, one workspace of an account at a time"
    XPosts:
      type: object
      required: [period, period_end, unlimited, included, used, included_left, credits_left, credits_expire_at, left, bundles, currency, link_post_weight]
      properties:
        period: { type: string, description: The month the included posts count in, examples: ["2026-10"] }
        period_end: { type: string, description: When the included posts reset }
        unlimited: { type: boolean, description: No allowance applies (the Community Edition) }
        included: { type: integer, description: "Posts to X the plan includes per month (-1 = unlimited)", examples: [450] }
        used: { type: integer, description: "Posts counted this month (included, bought and any overage)" }
        included_left: { type: integer }
        credits_left: { type: integer, description: Bought posts not used yet }
        credits_expire_at: { type: [string, "null"], description: When the soonest bought posts expire }
        left: { type: integer, description: included_left + credits_left }
        bundles:
          type: array
          items:
            type: object
            required: [posts, price]
            properties:
              posts: { type: integer, examples: [1000] }
              price: { type: integer, description: In whole units of currency, examples: [40] }
        currency: { type: string, description: "ISO 4217 code of the bundle prices, lower case", examples: [eur] }
        link_post_weight: { type: integer, description: How many posts a post with a live link counts as, examples: [14] }
    BillingDetails:
      type: object
      required: [enabled, subscription, payment_methods, invoices, has_more_invoices, managed_payments]
      properties:
        enabled: { type: boolean, description: Billing is configured on this instance }
        subscription:
          oneOf:
            - { $ref: "#/components/schemas/BillingSubscription" }
            - { type: "null" }
        payment_methods: { type: array, items: { $ref: "#/components/schemas/PaymentMethod" } }
        invoices: { type: array, items: { $ref: "#/components/schemas/Invoice" } }
        has_more_invoices: { type: boolean, description: Older invoices exist; pass the last id as starting_after }
        managed_payments: { type: boolean, description: "Checkouts on this server use Stripe Managed Payments: Stripe, through Link, is the merchant of record and sends the receipts and invoices" }
    BillingSubscription:
      type: object
      required: [status, current_period_start, current_period_end, cancel_at_period_end, cancel_at, canceled_at, trial_end, amount, currency, interval, quantity]
      properties:
        status: { type: string, description: "Stripe's status: active, trialing, past_due, canceled, unpaid, incomplete, ...", examples: [active] }
        current_period_start: { type: [string, "null"], description: Start of the billing period }
        current_period_end: { type: [string, "null"], description: End of the billing period (the next renewal) }
        cancel_at_period_end: { type: boolean, description: The subscription ends at the end of this period }
        cancel_at: { type: [string, "null"] }
        canceled_at: { type: [string, "null"] }
        trial_end: { type: [string, "null"] }
        amount: { type: integer, description: Price per seat (user) per interval in the smallest currency unit (cents), examples: [2900] }
        currency: { type: string, examples: [usd] }
        interval: { type: string, examples: [month] }
        quantity: { type: integer, description: "Seats billed: the workspace's members (the server keeps Stripe's quantity in step)", examples: [3] }
    PaymentMethod:
      type: object
      required: [brand, last4, exp_month, exp_year, default]
      properties:
        brand: { type: string, examples: [visa] }
        last4: { type: string, examples: ["4242"] }
        exp_month: { type: integer, examples: [12] }
        exp_year: { type: integer, examples: [2030] }
        default: { type: boolean, description: The card the subscription is charged to }
    Invoice:
      type: object
      required: [id, number, created, period_start, period_end, total, amount_paid, amount_due, currency, status, hosted_invoice_url, invoice_pdf]
      properties:
        id: { type: string, examples: [in_1Q2w3E4r5T6y7U8i] }
        number: { type: string, examples: [A1B2C3D4-0001] }
        created: { type: string }
        period_start: { type: [string, "null"] }
        period_end: { type: [string, "null"] }
        total: { type: integer, description: Smallest currency unit (cents) }
        amount_paid: { type: integer }
        amount_due: { type: integer }
        currency: { type: string, examples: [usd] }
        status: { type: string, description: "paid, open, void or uncollectible", examples: [paid] }
        hosted_invoice_url: { type: string, description: Stripe's page for the invoice (pay or download) }
        invoice_pdf: { type: string, description: The invoice as a PDF }
    BillingURL:
      type: object
      required: [url]
      properties:
        url: { type: string, examples: ["https://checkout.stripe.com/c/pay/cs_test_a1b2c3"] }
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error: { $ref: "#/components/schemas/ErrorBody" }
        request_id: { type: string, description: Same as the X-Request-Id response header }
        trace_id: { type: string, description: "W3C trace id from the request's traceparent header, when one was sent (for correlating with logs)" }
    ErrorBody:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: Stable machine-readable code; new codes may be added.
          examples: [bad_request, unauthorized, invalid_link, forbidden, csrf_failed, not_found, conflict, not_available, idempotency_in_progress, post_changed, already_free, sign_in_with_account, gone, link_used, link_expired, plan_limit_exceeded, plan_expired, payment_required, x_posts_exhausted, too_large, unsupported_media_type, validation_failed, not_editable, idempotency_mismatch, rate_limited, payment_provider_error, ai_provider_error, ai_key_rejected, timeout, busy, internal, default_group]
        message: { type: string }
        fields: { type: object, additionalProperties: { type: string } }
        details: { type: object, additionalProperties: true, description: "plan_limit_exceeded: limit, max, used, plan" }

    Scope:
      type: string
      description: |
        Writing and publishing are separate: `posts:write` creates and edits, `posts:publish`
        schedules, publishes, cancels, retries a target or reports a custom target's result.
        Deleting needs its own scope: `posts:delete` deletes posts and media, `collections:delete`
        deletes collections; the `:write` scopes never imply them. `channels:manage` and
        `alerts:manage` hold credentials. Purging stays `admin`. Dashboard sessions and the Community Edition's access token hold every scope.
      enum: ["collections:read", "collections:write", "collections:delete", "posts:read", "posts:write", "posts:delete", "posts:publish", "channels:read", "channels:manage", "schedules:manage", "media:read", "media:write", "webhooks:manage", "alerts:manage", "events:read", admin]

    Plan:
      type: object
      required: [id, name, price_month, price_year, currency, highlight, limits, features]
      properties:
        id: { type: string, description: "starter, plus, pro or business; unlocked (Community Edition)", examples: [plus] }
        name: { type: string }
        price_month: { type: integer, description: "Price per user (each workspace member is a seat) per month billed monthly, in whole units of currency (euros)", examples: [29] }
        price_year: { type: integer, description: "Price per user per year billed yearly: twelve whole months at 20 % off or a little more (rounded down)", examples: [276] }
        currency: { type: string, description: "ISO 4217 code, lower case", examples: [eur] }
        highlight: { type: boolean, description: The plan the picker suggests }
        limits: { $ref: "#/components/schemas/PlanLimits" }
        features: { type: array, items: { type: string } }
        entitlements:
          type: array
          items: { type: string }
          description: "A workspace's plan only: the Stripe features its subscription grants (lookup keys), e.g. channels_extra_10, ai_pack"
        base_limits: { $ref: "#/components/schemas/PlanLimits", description: "The plan's own limits, when entitlements changed limits" }
        limit_bonuses:
          type: array
          description: What each entitlement added to a limit
          items:
            type: object
            required: [limit, add, unlimited, from]
            properties:
              limit: { type: string, examples: [channels] }
              add: { type: integer, description: "Added to the limit (bytes for storage_bytes and max_upload_bytes)", examples: [10] }
              unlimited: { type: boolean, description: "The entitlement lifts the limit (or switches data_export on)" }
              from: { type: string, description: "The feature's name", examples: [Channels pack] }

    PlanLimits:
      type: object
      required: [posts_per_month, channels, alerts, webhooks, api_keys, storage_bytes, max_upload_bytes, data_export, x_posts_per_period]
      properties:
        posts_per_month: { type: integer, description: "Posts created per calendar month (people, agents and schedules); -1 = unlimited" }
        channels: { type: integer, description: "Connected channels; -1 = unlimited" }
        alerts: { type: integer }
        webhooks: { type: integer }
        api_keys: { type: integer }
        storage_bytes: { type: integer }
        max_upload_bytes: { type: integer }
        data_export: { type: boolean, description: "Full workspace exports with media files (backups) and event exports; false on Starter, where the workspace export is data only" }
        x_posts_per_period: { type: integer, description: "Posts to X included per month (each post and thread reply; a post with a live link counts 14). Bought credits come on top. -1 = unlimited", examples: [450] }

    Addon:
      type: object
      required: [id, name, description, features, price_month, price_year, currency, active]
      properties:
        id: { type: string, examples: [channels_pack] }
        name: { type: string, examples: [Channels pack] }
        description: { type: string }
        features: { type: array, items: { type: string } }
        price_month: { type: integer, description: "Per workspace per month, whole units of currency; 0 = not sold monthly", examples: [9] }
        price_year: { type: integer, description: "Per workspace per year; 0 = not sold yearly" }
        currency: { type: string, examples: [eur] }
        active: { type: boolean, description: On the workspace's subscription now }

    Role:
      type: string
      enum: [owner, admin, editor, viewer]
      description: "viewer: read; editor: write, schedule and publish; admin: everything in the workspace; owner: admin who may also manage owners"
    User:
      type: object
      required: [id, name, email, avatar_url]
      properties:
        id: { type: string }
        name: { type: string, description: "The Google name; in the Community Edition the computer's user name" }
        email: { type: string }
        avatar_url: { type: string }
    EmailCodeSent:
      type: object
      required: [resend_in, expires_in]
      properties:
        resend_in: { type: integer, description: Seconds until another code may be asked for }
        expires_in: { type: integer, description: Seconds the code works }
    SignInMethod:
      type: object
      required: [provider, subject, email, name, created_at, last_login_at]
      properties:
        provider: { type: string, enum: [google, email, passkey] }
        subject: { type: string, description: "Google's account id, the email address, or the passkey's id" }
        email: { type: string, description: "The address (empty for a passkey)" }
        name: { type: string, description: "A passkey's name (empty for the others)" }
        created_at: { type: string, format: date-time }
        last_login_at: { type: [string, "null"], format: date-time, description: "Last sign-in (a passkey: last used)" }
    PasskeyChallenge:
      type: object
      required: [challenge_id, options]
      properties:
        challenge_id: { type: string, description: Send it back with the browser's answer }
        options: { type: object, description: "WebAuthn options (PublicKeyCredentialCreationOptionsJSON or PublicKeyCredentialRequestOptionsJSON)" }
    SignInMethodList:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/SignInMethod" } }
    MyWorkspace:
      type: object
      required: [id, name, role, current]
      properties:
        id: { type: string }
        name: { type: string }
        role: { $ref: "#/components/schemas/Role" }
        current: { type: boolean }
    Account:
      type: object
      required: [id, name, owner, workspaces, created_at]
      properties:
        id: { type: string }
        name: { type: string }
        owner: { $ref: "#/components/schemas/User" }
        workspaces: { type: array, items: { $ref: "#/components/schemas/MyWorkspace" } }
        created_at: { type: string }
    Member:
      type: object
      required: [user_id, name, email, avatar_url, role, account_owner, you, created_at]
      properties:
        user_id: { type: string }
        name: { type: string }
        email: { type: string }
        avatar_url: { type: string }
        role: { $ref: "#/components/schemas/Role" }
        account_owner: { type: boolean }
        you: { type: boolean }
        created_at: { type: string }
    Invitation:
      type: object
      required: [id, email, role, invited_by, created_at, expires_at, expired]
      properties:
        id: { type: string }
        email: { type: string }
        role: { $ref: "#/components/schemas/Role" }
        invited_by: { type: string }
        created_at: { type: string }
        expires_at: { type: string }
        expired: { type: boolean }
        link: { type: string, description: Only in the response that created or re-sent it }
        emailed: { type: boolean, description: Whether the server emailed the link }
    InvitationPreview:
      type: object
      required: [workspace, email, role, invited_by, status, expires_at]
      properties:
        workspace: { type: string }
        email: { type: string }
        role: { $ref: "#/components/schemas/Role" }
        invited_by: { type: string }
        status: { type: string, enum: [open, expired, accepted, revoked] }
        expires_at: { type: string }

    Workspace:
      type: object
      required: [id, name, plan, recovery_rotated_at, created_at, timezone]
      properties:
        id: { type: string }
        name: { type: string }
        plan: { type: string, description: "starter, plus, pro or business (cloud; an operator may also set a plan of their own), unlocked (Community Edition)", examples: [plus] }
        recovery_rotated_at: { type: string }
        created_at: { type: string }
        timezone:
          type: string
          description: "IANA time zone name (default UTC; the Community Edition starts in the machine's zone). Post repeats keep their local time of day in it across DST, and a schedule rule sent without `tz` runs in it."
          examples: [Europe/Warsaw]

    APIKey:
      type: object
      required: [id, name, prefix, scopes, created_at, last_used_at, expires_at, revoked_at]
      properties:
        id: { type: string }
        name: { type: string }
        prefix: { type: string }
        scopes: { type: array, items: { $ref: "#/components/schemas/Scope" } }
        created_at: { type: string }
        last_used_at: { type: [string, "null"] }
        expires_at: { type: [string, "null"] }
        revoked_at: { type: [string, "null"] }
        key: { type: string, description: Raw secret; only in create/rotate responses }

    Session:
      type: object
      required: [id, user_agent, created_at, last_seen_at, expires_at, current]
      properties:
        id: { type: string }
        user_agent: { type: string }
        created_at: { type: string }
        last_seen_at: { type: string }
        expires_at: { type: string }
        current: { type: boolean }

    Collection:
      type: object
      required: [id, name, description, icon, color, processing_timeout_seconds, is_default, archived, archived_at, created_at, updated_at, post_count, status_counts]
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string }
        icon: { type: string }
        color: { type: string }
        processing_timeout_seconds:
          type: [integer, "null"]
          description: "How long a target on your own automation may wait for a result before it fails, in seconds (60 to 86400); null = the server's PROCESSING_TIMEOUT (see defaults.processing_timeout_seconds on GET /session)."
        is_default:
          type: boolean
          description: The workspace's Default collection. Every workspace has exactly one; it can be renamed but not archived or deleted (409).
        archived: { type: boolean }
        archived_at: { type: [string, "null"] }
        created_at: { type: string }
        updated_at: { type: string }
        post_count: { type: integer }
        status_counts: { type: object, additionalProperties: { type: integer }, description: Posts by status }

    CollectionInput:
      type: object
      properties:
        name: { type: string }
        description: { type: string }
        icon: { type: string }
        color: { type: string }
        processing_timeout_seconds: { type: [integer, "null"], minimum: 60, maximum: 86400, description: "How long targets on your own automation may wait for a result before they fail; null goes back to the server's PROCESSING_TIMEOUT." }
        archived: { type: boolean }

    Media:
      type: object
      description: An uploaded file
      required: [id, mime_type, size_bytes, original_name, url, sha256, created_at, alt_text, width, height, duration_ms, folder_id, tags]
      properties:
        id: { type: string }
        mime_type: { type: string }
        size_bytes: { type: integer }
        original_name: { type: string }
        url: { type: string, description: Absolute URL (PUBLIC_API_URL); requires authentication }
        sha256: { type: string }
        created_at: { type: string, format: date-time }
        alt_text: { type: string, description: "Describes a picture for people who can't see it; platforms that take alt text receive it" }
        width: { type: integer, description: "Pixels; measured on upload for pictures, reported by the uploading browser for videos; 0 when unknown" }
        height: { type: integer, description: Pixels; 0 when unknown }
        duration_ms: { type: integer, description: "A video's or audio file's length in milliseconds; 0 when unknown" }
        folder_id: { type: string, description: "The library folder the file sits in; empty for the top level" }
        tags: { type: array, items: { type: string } }
        deleted_at: { type: string, format: date-time, description: "Set while the file is in Deleted" }
        purge_at: { type: string, format: date-time, description: "When a file in Deleted is deleted for good" }
        duplicate_of: { type: string, description: "The oldest other file with the same content, if any" }

    MediaLibraryItem:
      type: object
      description: A file in the media library, with how many posts use it
      required: [id, mime_type, size_bytes, original_name, url, sha256, created_at, alt_text, width, height, duration_ms, folder_id, tags, uses]
      properties:
        id: { type: string }
        mime_type: { type: string }
        size_bytes: { type: integer }
        original_name: { type: string }
        url: { type: string, description: Absolute URL (PUBLIC_API_URL); requires authentication }
        sha256: { type: string }
        created_at: { type: string, format: date-time }
        alt_text: { type: string }
        width: { type: integer }
        height: { type: integer }
        duration_ms: { type: integer }
        folder_id: { type: string }
        tags: { type: array, items: { type: string } }
        deleted_at: { type: string, format: date-time, description: "Set while the file is in Deleted" }
        purge_at: { type: string, format: date-time, description: "When a file in Deleted is deleted for good" }
        duplicate_of: { type: string, description: "The oldest other file with the same content, if any" }
        uses: { type: integer, description: Posts that carry the file in their files or in a comment }

    MediaProject:
      type: object
      description: An editable picture of the media editor (its .gpex project file is at /v1/media-projects/{id}/file)
      required: [id, name, source_ids, output_id, size_bytes, created_at, updated_at]
      properties:
        id: { type: string }
        name: { type: string }
        source_ids: { type: array, items: { type: string }, description: The library files it was made from }
        output_id: { type: string, description: "The file it last saved; empty until saved" }
        size_bytes: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    MediaProjectUpload:
      type: object
      required: [file]
      description: Send the fields before the file
      properties:
        name: { type: string, maxLength: 200 }
        source_ids: { type: string, description: Comma-separated media ids }
        output_id: { type: string }
        file: { type: string, format: binary, description: The .gpex project file }
    AIStatus:
      type: object
      required: [configured, provider, model, key_hint, capabilities, reason, models, can_manage]
      properties:
        configured: { type: boolean }
        provider: { type: string, enum: [openai] }
        model: { type: string }
        key_hint: { type: string, description: "The key's last four characters, e.g. ...a1b2" }
        capabilities:
          type: object
          required: [generate, edit, inpaint, expand]
          properties:
            generate: { type: boolean }
            edit: { type: boolean }
            inpaint: { type: boolean, description: Change only a marked area }
            expand: { type: boolean, description: Fill new space around a picture }
        reason: { type: string, description: "Why AI is off or limited, in plain words; empty when everything works" }
        models: { type: array, items: { type: string } }
        can_manage: { type: boolean, description: The caller may set or remove the key (admin) }
        updated_at: { type: string, format: date-time }
    ImageRules:
      type: object
      description: "What a platform refuses in a picture (its API's documented limits); recommended shapes are not rules"
      properties:
        max_bytes: { type: integer }
        formats: { type: array, items: { type: string }, description: MIME types it takes; empty = any picture }
        min_ratio: { type: number, description: width / height }
        max_ratio: { type: number }
        min_width: { type: integer }
        max_pixels: { type: integer }
        note: { type: string }
    MediaFolder:
      type: object
      description: A virtual folder of the media library
      required: [id, parent_id, name, files, created_at]
      properties:
        id: { type: string }
        parent_id: { type: string, description: The folder it sits in; empty at the top level }
        name: { type: string }
        files: { type: integer, description: Files directly in this folder (not in its subfolders) }
        created_at: { type: string, format: date-time }

    MediaFolders:
      type: object
      required: [data, root_files, total_files, tags, deleted_files, duplicate_files]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/MediaFolder" } }
        root_files: { type: integer, description: Files at the top level }
        total_files: { type: integer, description: Files in the library (not in Deleted) }
        deleted_files: { type: integer, description: Files in Deleted }
        duplicate_files: { type: integer, description: Files with at least one identical twin }
        tags:
          type: array
          description: Every tag on files, most used first
          items:
            type: object
            required: [tag, files]
            properties:
              tag: { type: string }
              files: { type: integer }

    MediaPage:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/MediaLibraryItem" } }
        next_offset: { type: integer, description: Where the next page starts; absent on the last page }

    MediaUse:
      type: object
      required: [post_id, collection_id, title, status]
      properties:
        post_id: { type: string }
        collection_id: { type: string }
        title: { type: string, description: "The post's first line, shortened" }
        status: { $ref: "#/components/schemas/PostStatus" }
        publish_at: { type: string, format: date-time }

    PostStatus:
      type: string
      description: "draft (being written), scheduled (goes out at publish_at), publishing (a job is at work), published (every target is), failed (a target is; retry it or edit and publish again)"
      enum: [draft, scheduled, publishing, published, failed]

    TargetStatus:
      type: string
      enum: [pending, publishing, published, failed]

    PostTarget:
      type: object
      description: One channel a post goes to, with the result of publishing there
      required: [channel_id, channel_name, provider, avatar_url, content, settings, status, external_id, external_url, error, attempts, published_at, replies]
      properties:
        channel_id: { type: string }
        channel_name: { type: string }
        provider: { type: string, description: The channel's provider id }
        avatar_url: { type: string }
        content: { type: [string, "null"], description: "Text for this channel instead of the post's content; null uses the post's" }
        settings: { type: object, additionalProperties: { type: string }, description: "The provider's settings_fields by key (title, subreddit, visibility...)" }
        status: { $ref: "#/components/schemas/TargetStatus" }
        external_id: { type: string, description: "The platform's id of the published post" }
        external_url: { type: string, description: "Where the post lives on the platform" }
        error: { type: string }
        attempts: { type: integer }
        published_at: { type: [string, "null"], format: date-time }
        replies: { type: array, items: { $ref: "#/components/schemas/Reply" }, description: "How each thread part went on this channel, one per part" }

    Reply:
      type: object
      description: One thread part on one channel
      required: [index, status, external_id, external_url, error, published_at]
      properties:
        index: { type: integer, description: The thread part (0 is the first reply) }
        status:
          type: string
          enum: [pending, publishing, published, failed, skipped]
          description: "skipped: the platform takes no replies (your own automation gets the thread with the request instead), or an earlier part failed"
        external_id: { type: string }
        external_url: { type: string }
        error: { type: string }
        published_at: { type: [string, "null"], format: date-time }

    ThreadPart:
      type: object
      description: >-
        A follow-up part ("comment") published as a reply under the post, delay_seconds after the part before it
        (the first after the post). Needs content or media.
      properties:
        content: { type: string, maxLength: 50000 }
        media: { type: array, maxItems: 20, items: { type: string }, description: Media ids }
        delay_seconds: { type: integer, minimum: 0, maximum: 86400 }

    ThreadPartOut:
      type: object
      description: A thread part as the API returns it, with its media resolved
      required: [content, media, delay_seconds, media_items]
      properties:
        content: { type: string }
        media: { type: array, items: { type: string } }
        delay_seconds: { type: integer }
        media_items: { type: array, items: { $ref: "#/components/schemas/Media" } }

    PostTargetInput:
      type: object
      required: [channel_id]
      properties:
        channel_id: { type: string }
        content: { type: [string, "null"], description: "Text for this channel instead of the post's content" }
        settings: { type: object, additionalProperties: { type: string } }

    Post:
      type: object
      required: [id, collection_id, collection_name, content, media, media_items, status, publish_at, published_at, tags, metadata, source, external_ref, repeat_every, thread, version, targets, created_at, updated_at, created_by, open_comments]
      properties:
        id: { type: string }
        collection_id: { type: string }
        collection_name: { type: string }
        content: { type: string }
        media: { type: array, items: { type: string }, description: "Media ids, in order" }
        media_items: { type: array, items: { $ref: "#/components/schemas/Media" }, description: "The same media, resolved" }
        status: { $ref: "#/components/schemas/PostStatus" }
        publish_at: { type: [string, "null"], format: date-time, description: "When it goes out (scheduled), or the time it kept as a note (draft after cancel)" }
        published_at: { type: [string, "null"], format: date-time }
        tags: { type: array, items: { type: string } }
        metadata: { type: object, additionalProperties: true, description: Free JSON for your own use }
        source: { type: string, description: "Who created it: a client name, an agent, a schedule" }
        external_ref: { type: string, description: Your own reference }
        repeat_every: { $ref: "#/components/schemas/RepeatEvery" }
        thread: { type: array, items: { $ref: "#/components/schemas/ThreadPartOut" }, description: "Replies published under the post after it, in order" }
        version: { type: integer, description: Grows with every edit; see PostPatch.expected_version }
        targets: { type: array, items: { $ref: "#/components/schemas/PostTarget" } }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        created_by: { type: string, description: "Who created the post: a person, or the API key (agent, automation) that did; empty when unknown" }
        open_comments: { type: integer, description: "Reviewers' comment threads not resolved yet" }

    RepeatEvery:
      type: string
      enum: ["", 1d, 2d, 3d, 4d, 5d, 6d, 1w, 2w, 1mo]
      description: >-
        Repeat the post: once it is published, staqly schedules a copy this long after its publish time
        (every 1 to 6 days, every 1 or 2 weeks, every month); the copy repeats too. Empty: no repeat. To stop,
        clear repeat_every on the next scheduled copy, or delete it.
    PostInput:
      type: object
      properties:
        collection: { type: string, description: Collection id or exact name }
        content: { type: string, maxLength: 100000 }
        media: { type: array, maxItems: 20, items: { type: string }, description: Media ids from POST /v1/media }
        targets: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/PostTargetInput" } }
        publish_at: { type: [string, "null"], format: date-time, description: "In the future, at most a year ahead" }
        status: { type: string, enum: [draft, scheduled], description: "Default draft; scheduled needs publish_at and a target" }
        publish_now: { type: boolean, description: Publish right away (needs a target) }
        tags: { type: array, maxItems: 50, items: { type: string, maxLength: 50 } }
        metadata: { type: object, additionalProperties: true }
        source: { type: string, maxLength: 100 }
        external_ref: { type: string, maxLength: 200 }
        repeat_every: { $ref: "#/components/schemas/RepeatEvery" }
        thread: { type: array, maxItems: 25, items: { $ref: "#/components/schemas/ThreadPart" }, description: "Follow-up parts published as replies (platforms that take replies; your own automation gets them with the request)" }

    PostPatch:
      type: object
      description: Fields left out stay as they are
      properties:
        collection: { type: string, description: Move the post to another collection (id or exact name) }
        content: { type: string, maxLength: 100000 }
        media: { type: array, maxItems: 20, items: { type: string } }
        targets: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/PostTargetInput" }, description: Replaces the whole list }
        publish_at: { type: [string, "null"], format: date-time, description: "A time in the future, or null to clear it (a scheduled post becomes a draft)" }
        tags: { type: array, maxItems: 50, items: { type: string, maxLength: 50 } }
        metadata: { type: object, additionalProperties: true }
        external_ref: { type: string, maxLength: 200 }
        repeat_every:
          description: A repeat choice, or "" / null to stop repeating
          oneOf: [{ $ref: "#/components/schemas/RepeatEvery" }, { type: "null" }]
        thread: { type: array, maxItems: 25, items: { $ref: "#/components/schemas/ThreadPart" }, description: "Replaces the thread; [] clears it" }
        expected_version: { type: integer, description: "The version the edit is based on; 409 post_changed when the post moved on" }

    PostPage:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Post" } }
        next_cursor: { type: string }

    Metrics:
      type: object
      description: A post's numbers on one platform; a missing field is a number the platform does not give
      properties:
        impressions: { type: integer, minimum: 0 }
        likes: { type: integer, minimum: 0 }
        reposts: { type: integer, minimum: 0 }
        replies: { type: integer, minimum: 0 }
        quotes: { type: integer, minimum: 0 }
        bookmarks: { type: integer, minimum: 0 }
        link_clicks: { type: integer, minimum: 0 }
    TargetMetrics:
      type: object
      required: [channel_id, channel_name, provider, status, external_url, available, metrics, updated_at]
      properties:
        channel_id: { type: string }
        channel_name: { type: string }
        provider: { type: string }
        status: { type: string }
        external_url: { type: string }
        available: { type: boolean, description: The platform reports numbers (or your automation sends them) }
        metrics: { oneOf: [{ $ref: "#/components/schemas/Metrics" }, { type: "null" }] }
        updated_at: { type: [string, "null"], format: date-time }
        error: { type: string, description: Why the last read failed }
    PreviewLink:
      type: object
      required: [id, post_id, created_at, expires_at, revoked_at, active]
      properties:
        id: { type: string }
        post_id: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: [string, "null"], format: date-time }
        revoked_at: { type: [string, "null"], format: date-time }
        active: { type: boolean }
        url: { type: string, description: Only when created }
        token: { type: string, description: Only when created }
    ChannelLink:
      type: object
      required: [id, note, providers, group_id, max_uses, uses, status, created_by, created_at, expires_at]
      properties:
        id: { type: string }
        note: { type: string }
        providers: { type: array, items: { type: string }, description: Empty offers every available platform }
        group_id: { type: string, description: The channel group connected channels join (empty is Default) }
        max_uses: { type: integer }
        uses: { type: integer }
        status: { type: string, enum: [open, used, expired, revoked] }
        created_by: { type: string, description: Who made the link }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        revoked_at: { type: string, format: date-time }
        url: { type: string, description: Only when created }
        token: { type: string, description: Only when created }
    ChannelLinkPublic:
      type: object
      required: [workspace, note, providers, expires_at, uses_left]
      properties:
        workspace: { type: string, description: The workspace's name }
        note: { type: string }
        providers: { type: array, items: { $ref: "#/components/schemas/Provider" } }
        expires_at: { type: string, format: date-time }
        uses_left: { type: integer }
    PostComment:
      type: object
      description: >-
        One comment of a feedback thread. A thread starts with a comment whose parent_id is empty; it may mark a
        passage of the post (quote, in part `part` of channel `channel_id`'s version). Replies carry the thread's
        first comment as parent_id.
      required: [id, post_id, parent_id, author_name, author_kind, body, quote, channel_id, part, anchor_start, anchor_end, text_changed, resolved_at, resolved_by, created_at]
      properties:
        id: { type: string }
        post_id: { type: string }
        parent_id: { type: string, description: "The thread's first comment; empty for that first comment" }
        author_name: { type: string }
        author_email: { type: string, description: Only for the team }
        author_kind: { type: string, enum: [reviewer, team], description: "reviewer: left on a preview page; team: a person of the workspace, an API key or an agent" }
        body: { type: string }
        quote: { type: string, description: The marked words (empty for a general comment and for replies) }
        channel_id: { type: string, description: "The channel's version the passage is in; empty: the shared text" }
        part: { type: integer, description: "0: the post's text; n: the n-th comment of its thread" }
        anchor_start: { type: integer, description: "Where the passage starts in that text now, in UTF-16 code units (as browsers count); -1 when there is none" }
        anchor_end: { type: integer, description: "Where it ends (exclusive); -1 when there is none" }
        text_changed: { type: boolean, description: The marked words are no longer in the text; quote keeps them }
        resolved_at: { type: [string, "null"], format: date-time, description: When the thread was resolved (first comments only) }
        resolved_by: { type: string, description: Who resolved it }
        created_at: { type: string, format: date-time }
    PreviewMedia:
      type: object
      required: [id, mime_type]
      properties:
        id: { type: string }
        mime_type: { type: string }
    PublicPreview:
      type: object
      required: [workspace, content, status, publish_at, published_at, media, thread, targets, comments, expires_at]
      properties:
        workspace: { type: string }
        content: { type: string }
        status: { type: string }
        publish_at: { type: [string, "null"], format: date-time }
        published_at: { type: [string, "null"], format: date-time }
        media: { type: array, items: { $ref: "#/components/schemas/PreviewMedia" } }
        thread:
          type: array
          items:
            type: object
            required: [content, media, delay_seconds]
            properties:
              content: { type: string }
              media: { type: array, items: { $ref: "#/components/schemas/PreviewMedia" } }
              delay_seconds: { type: integer }
        targets:
          type: array
          items:
            type: object
            required: [channel_id, channel_name, handle, avatar_url, provider, provider_name, content, status, external_url, published_at]
            properties:
              channel_id: { type: string }
              channel_name: { type: string }
              handle: { type: string }
              avatar_url: { type: string }
              provider: { type: string }
              provider_name: { type: string }
              content: { type: string, description: The text this channel gets }
              status: { type: string }
              external_url: { type: string }
              published_at: { type: [string, "null"], format: date-time }
        comments: { type: array, items: { $ref: "#/components/schemas/PostComment" } }
        expires_at: { type: [string, "null"], format: date-time }
    PostPreview:
      type: object
      required: [targets]
      properties:
        targets:
          type: array
          items:
            type: object
            required: [channel_id, text, links, links_found, warnings, ok]
            properties:
              channel_id: { type: string }
              text: { type: string, description: "The text this target will carry: its own version if it has one, with the channel's links mode applied" }
              links: { $ref: "#/components/schemas/LinksMode" }
              links_found: { type: array, items: { type: string }, description: "The links the channel's links mode removes or defangs, as written (empty with keep)" }
              warnings: { type: array, items: { type: string }, description: "What the provider objects to (too long, media missing...) and, when links_found is not empty, what happens to them (\"Links will be removed from: Brand on X (https://example.com)\")" }
              ok: { type: boolean }

    Tag:
      type: object
      required: [name, count]
      properties:
        name: { type: string }
        count: { type: integer, description: How many posts carry it }

    WorkItem:
      type: object
      description: Something the workspace asks your automation for
      required: [kind, collection, requested_at]
      properties:
        kind: { type: string, enum: [target, posts], description: "target: publish this post on your own automation and report the result; posts: a schedule found nothing to publish" }
        post: { $ref: "#/components/schemas/Post" }
        channel: { $ref: "#/components/schemas/Channel" }
        schedule: { $ref: "#/components/schemas/Schedule" }
        collection: { $ref: "#/components/schemas/Collection" }
        requested_at: { type: string, format: date-time }

    ChannelStatus:
      type: string
      description: "connected (works), needs_reconnect (the platform refused the credentials), disabled (turned off; targets on it fail without trying)"
      enum: [connected, needs_reconnect, disabled]

    Channel:
      type: object
      description: A connected account. Credentials are stored sealed and never returned.
      required: [id, provider, name, handle, avatar_url, profile_url, status, settings, links, group_id, last_error, created_at, updated_at]
      properties:
        id: { type: string }
        provider: { type: string, description: Provider id (see GET /v1/providers) }
        name: { type: string }
        handle: { type: string, description: "The account's handle on the platform (@name, a channel id)" }
        avatar_url: { type: string }
        profile_url: { type: string }
        status: { $ref: "#/components/schemas/ChannelStatus" }
        settings: { type: object, additionalProperties: true, description: "Provider-level defaults for new targets, and `links` (one of the provider's limits.link_modes) to choose what the channel does with links" }
        links: { $ref: "#/components/schemas/LinksMode", description: "What this channel does with the links in a post's text: settings.links, else the provider's limits.links" }
        group_id: { type: string, description: "The channel group it belongs to (every channel is in exactly one; the Default group unless moved)" }
        last_error: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        activity: { $ref: "#/components/schemas/ChannelActivity" }

    ChannelActivity:
      type: object
      description: Read from the channel's targets. Only GET /v1/channels and GET /v1/channels/{id} include it (not webhook payloads).
      required: [last_published_at, last_post_id, next_publish_at, published_30d]
      properties:
        last_published_at: { type: [string, "null"], format: date-time, description: When a post last went out on this channel }
        last_post_id: { type: [string, "null"], description: That post }
        next_publish_at: { type: [string, "null"], format: date-time, description: When the next scheduled post goes out on this channel }
        published_30d: { type: integer, description: Posts published on this channel in the last 30 days }

    ChannelInput:
      type: object
      properties:
        provider: { type: string, description: "Provider id from GET /v1/providers (custom, telegram, bluesky, ...)" }
        name: { type: string, maxLength: 100, description: "What to call the channel; defaults to the account's name" }
        fields: { type: object, additionalProperties: { type: string }, description: "The provider's credential_fields by key (token platforms)", writeOnly: true }
        settings: { type: object, additionalProperties: true }
        group_id: { type: string, description: "Channel group to put it in (grp_...); default: the Default group" }

    ChannelPatch:
      type: object
      properties:
        name: { type: string, maxLength: 100 }
        settings: { type: object, additionalProperties: true, description: "Replaces the settings; `links` (keep, remove, defang; one of the provider's limits.link_modes) sets what the channel does with links" }
        status: { type: string, enum: [connected, disabled] }
        fields: { type: object, additionalProperties: { type: string }, description: "Token platforms: fresh credential fields to reconnect with", writeOnly: true }
        group_id: { type: string, description: "Moves the channel to this channel group (grp_...); empty moves it to Default" }

    ChannelGroup:
      type: object
      description: The one bucket a channel sits in (a group per client, say). The Default group always exists and can't be deleted.
      required: [id, name, color, position, default, channels, created_at, updated_at]
      properties:
        id: { type: string }
        name: { type: string }
        color: { type: string, description: "#rrggbb, or empty" }
        position: { type: integer, description: Sort order (Default always leads) }
        default: { type: boolean }
        channels: { type: integer, description: Channels in the group (list only; 0 elsewhere) }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ChannelGroupInput:
      type: object
      properties:
        name: { type: string, maxLength: 60, description: Unique in the workspace (case-insensitive); required on create }
        color: { type: string, description: "#rrggbb, or empty" }
        position: { type: integer }

    DeveloperAppInput:
      type: object
      description: An OAuth client the workspace registered with a platform
      properties:
        client_id: { type: string }
        client_secret: { type: string, writeOnly: true }

    ProviderField:
      type: object
      description: A form field a provider needs (a credential to connect, or a setting per target)
      required: [key, label, kind, required]
      properties:
        key: { type: string }
        label: { type: string }
        help: { type: string }
        kind:
          type: string
          enum: [text, secret, url, select, textarea, boolean, number, list, channels]
          description: "list: comma-separated free values (tags, usernames). channels: comma-separated ids of other connected channels of the same platform (for example the accounts that repost a post). Booleans are \"true\" / \"false\"."
        max_length: { type: integer, description: "At most this many characters (text, textarea, url)" }
        max_items: { type: integer, description: "At most this many values (list, channels)" }
        options:
          type: array
          items:
            type: object
            required: [value, label]
            properties:
              value: { type: string }
              label: { type: string }
        required: { type: boolean }
        default: { type: string }
        placeholder: { type: string }
        when:
          type: object
          description: "Show the field, and apply required, only while the field `key` holds `value` (the dashboard counts defaults; the API only what was sent)"
          required: [key, value]
          properties:
            key: { type: string }
            value: { type: string }

    ProviderLimits:
      type: object
      description: What the composer and the publish validation apply
      required: [max_chars, max_media, needs_title, needs_media, needs_link, links, link_modes]
      properties:
        max_chars: { type: integer, description: 0 = no limit }
        max_media: { type: integer, description: 0 = no media }
        media_kinds: { type: array, items: { type: string, enum: [image, video, gif, file] } }
        needs_title: { type: boolean, description: A title setting is required }
        needs_media: { type: boolean, description: Text alone is not a post }
        needs_link: { type: boolean, description: A link setting is required }
        links: { $ref: "#/components/schemas/LinksMode", description: "What a channel does with links unless its settings.links says otherwise" }
        link_modes: { type: array, items: { $ref: "#/components/schemas/LinksMode" }, description: "The links modes a channel on this platform may choose (X: remove, defang)" }
        image: { $ref: "#/components/schemas/ImageRules" }

    LinksMode:
      type: string
      enum: [keep, remove, defang]
      description: "What a channel does with the links in a post's text, at publish time (the post keeps its text): keep them, remove them, or defang them into readable, non-clickable text (https://example.com/a becomes example[.]com/a)"

    Provider:
      type: object
      description: A platform staqly can publish to
      required: [id, name, auth, status, limits, credential_fields, settings_fields, app_configured, docs_url]
      properties:
        id: { type: string, examples: [custom, telegram, bluesky, mastodon] }
        name: { type: string }
        auth: { type: string, enum: [oauth2, token, none] }
        status: { type: string, enum: [available, soon] }
        limits: { $ref: "#/components/schemas/ProviderLimits" }
        credential_fields: { type: array, items: { $ref: "#/components/schemas/ProviderField" }, description: What POST /v1/channels needs in `fields` (token platforms) }
        settings_fields: { type: array, items: { $ref: "#/components/schemas/ProviderField" }, description: Per-target settings a post may set }
        app_configured: { type: boolean, description: "OAuth platforms: a developer app is set (the workspace's or the server's)" }
        docs_url: { type: string, format: uri }

    ScheduleRule:
      type: object
      description: "When a recurring schedule runs: times (HH:MM) on weekdays in tz, or every N minutes"
      properties:
        times: { type: array, maxItems: 24, items: { type: string, pattern: "^[0-2][0-9]:[0-5][0-9]$" }, examples: [["09:00", "17:30"]] }
        weekdays: { type: array, items: { type: integer, minimum: 0, maximum: 6 }, description: "0 = Sunday ... 6 = Saturday; empty = every day" }
        tz: { type: string, description: "IANA name; empty = the workspace's time zone", examples: [Europe/Warsaw] }
        every_minutes: { type: integer, minimum: 5, maximum: 10080 }

    Schedule:
      type: object
      required: [id, collection_id, collection_name, name, channels, channel_names, kind, run_at, rule, pick, post_ids, source, endpoint_url, enabled, next_run_at, last_run_at, last_result, created_at, updated_at]
      properties:
        id: { type: string }
        collection_id: { type: string }
        collection_name: { type: string }
        name: { type: string }
        channels: { type: array, items: { type: string }, description: Channel ids the chosen posts go to (a post's own targets win) }
        channel_names: { type: array, items: { type: string } }
        kind: { type: string, enum: [once, recurring] }
        run_at: { type: [string, "null"], format: date-time, description: once only }
        rule: { $ref: "#/components/schemas/ScheduleRule" }
        pick: { type: string, enum: [next, selected, all], description: "next: the oldest waiting draft; selected: post_ids; all: every waiting draft" }
        post_ids: { type: array, items: { type: string } }
        source: { type: string, enum: [none, agent, endpoint], description: "When nothing waits: do nothing, ask an agent (schedule.posts_requested, GET /v1/work) or fetch posts from endpoint_url" }
        endpoint_url: { type: string }
        endpoint_secret: { type: string, description: "source endpoint only: the HMAC key of X-Staqly-Signature (sha256=hex(HMAC_SHA256(secret, timestamp + '.' + body)), like a webhook). Shown in full only when staqly generated it, in that create or update response (send an empty endpoint_secret to generate one); a secret you chose is never echoed back; elsewhere masked as ****<last 4>." }
        enabled: { type: boolean }
        next_run_at: { type: [string, "null"], format: date-time }
        last_run_at: { type: [string, "null"], format: date-time }
        last_result: { type: string, description: "What the last run did, or why it failed" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ScheduleInput:
      type: object
      properties:
        collection: { type: string, description: Collection id or exact name (create only) }
        name: { type: string, maxLength: 100 }
        channels: { type: array, maxItems: 50, items: { type: string } }
        kind: { type: string, enum: [once, recurring] }
        run_at: { type: string, format: date-time, description: "once: in the future" }
        rule: { $ref: "#/components/schemas/ScheduleRule" }
        pick: { type: string, enum: [next, selected, all] }
        post_ids: { type: array, maxItems: 200, items: { type: string } }
        source: { type: string, enum: [none, agent, endpoint] }
        endpoint_url: { type: string, format: uri }
        endpoint_secret: { type: string, description: "Signs the request like a webhook (X-Staqly-Signature); generated when empty, and returned in full once in this response" }
        enabled: { type: boolean }

    NotificationChoice:
      type: object
      required: [event, email, in_app]
      properties:
        event: { type: string, enum: [post.published, post.failed, post.feedback_added, channel.needs_reconnect, schedule.posts_requested, invitation.accepted] }
        email: { type: boolean }
        in_app: { type: boolean, description: Shown in the bell }
    NotificationPrefs:
      type: object
      required: [scope, events, email, mail_ready]
      properties:
        scope: { type: string, enum: [mine, all] }
        events: { type: array, items: { $ref: "#/components/schemas/NotificationChoice" } }
        email: { type: string, description: Where the emails go (the address the person signs in with) }
        mail_ready: { type: boolean, description: The server can send email }
    Job:
      type: object
      description: One unit of background work
      required: [id, kind, key, status, run_at, attempts, max_attempts, lease_until, leased_by, last_error, payload, created_at, updated_at, done_at]
      properties:
        id: { type: string }
        kind: { type: string, enum: [publish_post, publish_target, target_timeout, run_schedule, refresh_channel, repeat_post, publish_reply, repost, notify_people, notify_person] }
        key: { type: string, description: "Unique per subject (publish_post:<post id>); queueing the same key again replaces a queued job, and runs a running one again when it ends" }
        status: { type: string, enum: [queued, running, done, failed, cancelled] }
        run_at: { type: string, format: date-time }
        attempts: { type: integer }
        max_attempts: { type: integer }
        lease_until: { type: [string, "null"], format: date-time, deprecated: true, description: Always null (the queue no longer uses leases) }
        leased_by: { type: string, description: The worker that ran the last attempt }
        last_error: { type: string }
        payload:
          type: object
          properties:
            post_id: { type: string }
            channel_id: { type: string }
            schedule_id: { type: string }
            index: { type: integer, description: "publish_reply: the thread part" }
            parent_id: { type: string, description: "publish_reply: the platform id the reply goes under" }
            repost_by: { type: string, description: "repost: the channel that reposts" }
            event_id: { type: string, description: "notify_people, notify_person: the event to tell people about" }
            user_id: { type: string, description: "notify_person: who gets the email" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        done_at: { type: [string, "null"], format: date-time }

    JobPage:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Job" } }
        next_cursor: { type: string }

    JobStats:
      type: object
      required: [queued, running, failed, done_24h, by_kind]
      properties:
        queued: { type: integer }
        running: { type: integer }
        failed: { type: integer }
        done_24h: { type: integer, description: Finished in the last 24 hours }
        by_kind:
          type: object
          additionalProperties:
            type: object
            required: [queued, running, failed]
            properties:
              queued: { type: integer }
              running: { type: integer }
              failed: { type: integer }

    Event:
      type: object
      required: [id, seq, type, collection_id, post_id, actor, data, created_at]
      properties:
        id: { type: string }
        seq: { type: integer }
        type: { type: string, examples: [post.created, post.scheduled, post.publish_requested, post.published, post.failed, channel.needs_reconnect, schedule.ran] }
        collection_id: { type: [string, "null"] }
        post_id: { type: [string, "null"] }
        channel_id: { type: [string, "null"], description: Channel events }
        schedule_id: { type: [string, "null"], description: Schedule events }
        actor:
          type: object
          required: [type, id, name]
          properties:
            type: { type: string, enum: [local, session, api_key, system] }
            id: { type: string }
            name: { type: string }
            agent: { type: string, description: "The MCP or REST client that acted (as listed by /v1/agents), when known" }
        data: { type: object, additionalProperties: true }
        created_at: { type: string }

    EventPage:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Event" } }
        next_cursor: { type: string }

    Webhook:
      type: object
      required: [id, url, description, secret, events, collection_ids, active, timeout_ms, max_attempts, created_at, updated_at]
      properties:
        id: { type: string }
        url: { type: string }
        description: { type: string }
        secret: { type: string, description: "HMAC key; X-Staqly-Signature = sha256=hex(HMAC_SHA256(secret, timestamp + '.' + body)). Shown in full only in the create response and after rotate_secret; elsewhere masked as whsec_****<last 4>." }
        events: { type: array, items: { type: string }, description: 'Exact names or globs: "*", "post.*"' }
        collection_ids: { type: array, items: { type: string }, description: Empty means all collections }
        active: { type: boolean }
        timeout_ms: { type: integer }
        max_attempts: { type: integer }
        created_at: { type: string }
        updated_at: { type: string }

    WebhookInput:
      type: object
      properties:
        url: { type: string }
        description: { type: string }
        events:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string, maxLength: 100 }
          description: '"*", a glob inside a known event family ("post.*", "channel.*", "schedule.*") or a known event type (post.created, post.scheduled, post.publish_requested, post.target_published, post.published, post.failed, channel.needs_reconnect, schedule.ran...). Unknown patterns are a 422.'
        collection_ids: { type: array, items: { type: string } }
        active: { type: boolean }
        timeout_ms: { type: integer, minimum: 1000, maximum: 60000 }
        max_attempts: { type: integer, minimum: 1, maximum: 20 }
        rotate_secret: { type: boolean }

    Delivery:
      type: object
      required: [id, webhook_id, event_id, event_type, status, attempts, next_attempt_at, last_status_code, last_error, last_response, last_attempt_at, delivered_at, created_at]
      properties:
        id: { type: string }
        webhook_id: { type: string }
        event_id: { type: string }
        event_type: { type: string }
        status: { type: string, enum: [pending, succeeded, failed] }
        attempts: { type: integer }
        next_attempt_at: { type: [string, "null"] }
        last_status_code: { type: [integer, "null"] }
        last_error: { type: string }
        last_response: { type: string, description: "First 2 KB of the receiver's response body (Community Edition only; always empty in cloud mode)" }
        last_attempt_at: { type: [string, "null"] }
        delivered_at: { type: [string, "null"] }
        created_at: { type: string }

    WorkspaceExport:
      type: object
      required: [id, status, size_bytes, created_at, started_at, finished_at, expires_at, includes_media]
      properties:
        id: { type: string }
        status: { type: string, enum: [pending, running, ready, failed, expired] }
        includes_media: { type: boolean, description: "false for a data-only export (Starter): no media/ files in the ZIP" }
        size_bytes: { type: integer }
        error: { type: string }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, "null"], format: date-time }
        finished_at: { type: [string, "null"], format: date-time }
        expires_at: { type: string, format: date-time }
        download_url: { type: string, description: "Relative URL, present when status is ready" }

    PurgeResult:
      type: object
      required: [post_id, events_deleted, media_deleted]
      properties:
        post_id: { type: string }
        events_deleted: { type: integer }
        media_deleted: { type: integer, description: Media files deleted because no other post used them }

    Readiness:
      type: object
      required: [status, checks]
      properties:
        status: { type: string, enum: [ok, unavailable] }
        checks:
          type: object
          properties:
            database: { type: string, enum: [ok, unavailable] }
            migrations: { type: string, description: "ok, unknown, or pending (applied < embedded)" }
            workers: { type: string, enum: [ok, not_started, stale, stopped] }

    AlertConfig:
      type: object
      additionalProperties: false
      description: >
        Type-specific delivery settings. Secret fields are write-only (returned masked, e.g. "••••abcd").
        telegram: bot_token*, chat_id*.
        slack: webhook_url* or bot_token*+channel*.
        discord: webhook_url* or bot_token*+channel*.
        ntfy: topic*, server_url (default https://ntfy.sh), access_token, priority.
        email: host*, port, username, password, from*, to*, tls (starttls|tls|none).
        On update, keys are merged into the stored config and `null` removes a key.
      properties:
        bot_token: { type: [string, "null"], writeOnly: true, description: Secret (Telegram/Slack/Discord bot token) }
        chat_id: { type: [string, "null"] }
        webhook_url: { type: [string, "null"], format: uri, description: Secret (Slack/Discord webhook URL) }
        channel: { type: [string, "null"], description: Slack channel ID or Discord channel ID (bot mode) }
        server_url: { type: [string, "null"], format: uri }
        topic: { type: [string, "null"] }
        access_token: { type: [string, "null"], description: Secret (ntfy token) }
        priority: { type: [integer, "null"], minimum: 1, maximum: 5 }
        host: { type: [string, "null"] }
        port: { type: [integer, "null"] }
        username: { type: [string, "null"] }
        password: { type: [string, "null"], description: Secret (SMTP password) }
        from: { type: [string, "null"] }
        to: { type: [array, "null"], items: { type: string, format: email } }
        tls: { type: [string, "null"], enum: [starttls, tls, none, null] }
    AlertInput:
      type: object
      additionalProperties: false
      properties:
        name: { type: string, maxLength: 100 }
        type: { type: string, enum: [telegram, slack, discord, ntfy, email], description: Required on create; immutable }
        enabled: { type: boolean }
        config: { $ref: "#/components/schemas/AlertConfig" }
        events:
          type: array
          items: { type: string }
          description: 'Event patterns (glob, "*"). Default ["post.published", "post.failed", "channel.needs_reconnect"].'
        collection_ids: { type: array, items: { type: string } }
        digest_window_seconds: { type: integer, minimum: 0, maximum: 86400, description: Batch events within this window into one message (0 = immediately) }
    Alert:
      type: object
      required: [id, name, type, enabled, config, events, collection_ids, digest_window_seconds, fail_count, last_error, disabled_reason, last_sent_at, created_at, updated_at]
      properties:
        id: { type: string }
        name: { type: string }
        type: { type: string, enum: [telegram, slack, discord, ntfy, email] }
        enabled: { type: boolean }
        config: { type: object, additionalProperties: true, description: Settings with secrets masked }
        events: { type: array, items: { type: string } }
        collection_ids: { type: array, items: { type: string } }
        digest_window_seconds: { type: integer }
        fail_count: { type: integer, description: Consecutive failed sends; the channel is disabled at NOTIFY_MAX_FAILS }
        last_error: { type: string }
        disabled_reason: { type: string }
        last_sent_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    AlertDelivery:
      type: object
      required: [id, channel_id, event_id, event_type, post_id, status, attempts, next_attempt_at, last_error, sent_at, created_at]
      properties:
        id: { type: string }
        channel_id: { type: string }
        event_id: { type: string }
        event_type: { type: string }
        post_id: { type: [string, "null"] }
        status: { type: string, enum: [pending, sent, failed, skipped] }
        attempts: { type: integer }
        next_attempt_at: { type: [string, "null"], format: date-time }
        last_error: { type: string }
        sent_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

  headers:
    X-Request-Id:
      description: Request id (the caller's X-Request-Id if sent, else generated). Present on every response.
      schema: { type: string }
    Retry-After:
      description: Seconds until a retry may succeed (sent with 429, and with 503 `busy`).
      schema: { type: integer, minimum: 1 }
