openapi: 3.1.0
info:
  title: Outflare API
  version: 0.1.0
  description: |
    The Outflare REST API. Every client (the web app, the CLI, SDKs, MCP
    servers and your own agents) uses this same API.

    Agents acting toward a goal send `Outflare-Mission: <mission id>` (and
    optionally `Outflare-Reason: <why>`) on their calls. The call is logged
    against the mission, and a write may be answered `202` with a
    `MissionHeld` body instead of running: a dry run, or held until a
    person approves it. See the missions endpoints.

    Every operation says who may call it in `x-outflare-access`: `scope`
    (the API token scope it needs in the workspace) and `project_tokens`
    (whether a project token may call it), `agency_role` and
    `agency_tokens` for agency endpoints, or `signed_in` for endpoints
    only a signed-in person can use. Operations without it are public.
    The engine's tests check every value against the running server.

    Workspace writes take an `Idempotency-Key` header: a retry with the
    same key gets the first answer back instead of running twice.

    Errors always have the `Error` body (`default` on every operation).
    Workspace writes may also answer `202` with `MissionHeld` when made
    inside a mission.
  license:
    name: AGPL-3.0-only
    identifier: AGPL-3.0-only
security:
  - bearer: []
  - session: []
servers:
  - url: http://localhost:8080
    description: Local self-hosted instance
paths:
  /healthz:
    get:
      operationId: getHealth
      security: []
      summary: Liveness check
      tags: [system]
      responses:
        "200":
          description: The process is up.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Status"
        default: { $ref: "#/components/responses/Error" }
  /readyz:
    get:
      operationId: getReadiness
      security: []
      summary: Readiness check, including the database
      tags: [system]
      responses:
        "200":
          description: Ready to serve traffic.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Status"
        "503":
          description: A dependency is unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        default: { $ref: "#/components/responses/Error" }
  /v1/meta:
    get:
      operationId: getMeta
      security: []
      summary: Instance version and capabilities
      tags: [system]
      responses:
        "200":
          description: Instance metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Meta"
        default: { $ref: "#/components/responses/Error" }
  /v1/openapi.yaml:
    get:
      operationId: getOpenAPI
      security: []
      summary: This document
      tags: [system]
      responses:
        "200":
          description: The OpenAPI document.
          content:
            application/yaml: {}
        default: { $ref: "#/components/responses/Error" }
  /.well-known/outflare.json:
    get:
      operationId: getDiscovery
      security: []
      summary: Machine-readable discovery for agents and tools
      tags: [system]
      responses:
        "200":
          description: Discovery document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Meta"
        default: { $ref: "#/components/responses/Error" }
  /v1/setup:
    get:
      operationId: getSetupStatus
      summary: Whether first-run setup is still needed
      tags: [access]
      security: []
      responses:
        "200":
          description: Setup status.
          content:
            application/json:
              schema:
                type: object
                required: [needed]
                properties:
                  needed: { type: boolean }
                  telemetry:
                    description: Whether this server sends anonymous usage stats, shown on the first-run form.
                    type: object
                    required: [enabled, disabled_by]
                    properties:
                      enabled: { type: boolean }
                      disabled_by: { $ref: "#/components/schemas/TelemetryOffReason" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: setup
      summary: Create the first user and workspace
      description: Only works while the instance has no users. Signs the new owner in.
      tags: [access]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, name, password, workspace_name]
              properties:
                email: { type: string, format: email }
                name: { type: string }
                password: { type: string, minLength: 10 }
                workspace_name: { type: string }
                telemetry:
                  type: boolean
                  description: false turns off anonymous usage stats and update checks from the start. Default true.
      responses:
        "201":
          description: Created; a session cookie is set.
          content:
            application/json:
              schema:
                type: object
                required: [user_id, workspace_id]
                properties:
                  user_id: { type: string, format: uuid }
                  workspace_id: { type: string, format: uuid }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/auth/login:
    post:
      operationId: login
      summary: Sign in with email and password
      description: Sets a session cookie. Ten failed attempts for one email lock it for 15 minutes.
      tags: [access]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string }
                password: { type: string }
      responses:
        "200": { description: Signed in. }
        "401": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/auth/logout:
    post:
      operationId: logout
      summary: End the current session
      tags: [access]
      responses:
        "204": { description: Signed out. }
        default: { $ref: "#/components/responses/Error" }
  /v1/auth/password-reset:
    post:
      operationId: requestPasswordReset
      summary: Email a password reset link
      description: |
        Sends a one-time link (valid an hour) to `<PUBLIC_URL>/reset-password#<token>`, through the account email service
        (see `account_email` in /v1/meta). The answer is the same whether or not the email has an account. Ten requests
        for one email, or from one network, in 15 minutes are refused.
      tags: [access]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string }
      responses:
        "202": { description: Sent, if the email has an account. }
        "400": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/auth/password-reset/confirm:
    post:
      operationId: confirmPasswordReset
      summary: Choose a new password with a reset link
      description: Sets the password, signs out every session, signs this browser in, and counts the email as confirmed.
      tags: [access]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token: { type: string, description: The part of the link after `#`. }
                password: { type: string, minLength: 10 }
      responses:
        "200": { description: Password changed and signed in. }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/auth/verify-email:
    post:
      operationId: verifyEmail
      summary: Confirm an email address with its link
      description: The link is `<PUBLIC_URL>/verify-email#<token>`, valid 48 hours, sent when an account is made with a password.
      tags: [access]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "200":
          description: Confirmed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailVerification" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/me/email-verification:
    post:
      operationId: resendEmailVerification
      x-outflare-access: { signed_in: true }
      summary: Send the confirmation link again
      description: Replaces any earlier link. Answers 200 without sending when the address is already confirmed.
      tags: [access]
      responses:
        "200":
          description: Already confirmed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailVerification" }
        "202":
          description: Sent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailVerification" }
        "429": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/auth/passkey/options:
    post:
      operationId: startPasskeySignIn
      summary: Start signing in with a passkey
      description: |
        Returns WebAuthn request options (`publicKey`) for `navigator.credentials.get`, with no allowed credentials so the
        browser offers whichever passkeys it holds for this site. Sets a short-lived `outflare_passkey` cookie holding the
        challenge. Passkeys are on when PUBLIC_URL is https (or PASSKEYS=on); `passkey` is then in `login_methods`.
      tags: [access]
      security: []
      responses:
        "200":
          description: Options for the browser.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/auth/passkey:
    post:
      operationId: passkeySignIn
      summary: Finish signing in with a passkey
      description: The body is the browser's assertion (`PublicKeyCredential.toJSON()`). Sets a session cookie.
      tags: [access]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200": { description: Signed in. }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/me/passkeys:
    get:
      operationId: listPasskeys
      x-outflare-access: { signed_in: true }
      summary: Your passkeys
      tags: [access]
      responses:
        "200":
          description: Passkeys that sign you in.
          content:
            application/json:
              schema:
                type: object
                required: [passkeys]
                properties:
                  passkeys:
                    type: array
                    items: { $ref: "#/components/schemas/Passkey" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: addPasskey
      x-outflare-access: { signed_in: true }
      summary: Save a new passkey
      description: The body is the browser's new credential (`PublicKeyCredential.toJSON()`), answering /v1/me/passkeys/options.
      tags: [access]
      parameters:
        - { name: name, in: query, schema: { type: string, maxLength: 80 }, description: "A label, such as \"MacBook\". Default Passkey." }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "201":
          description: Saved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Passkey" }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/me/passkeys/options:
    post:
      operationId: startAddingPasskey
      x-outflare-access: { signed_in: true }
      summary: Start adding a passkey
      description: Returns WebAuthn creation options (`publicKey`) for `navigator.credentials.create`, and sets the `outflare_passkey` challenge cookie. Up to 10 passkeys.
      tags: [access]
      responses:
        "200":
          description: Options for the browser.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/me/passkeys/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      operationId: renamePasskey
      x-outflare-access: { signed_in: true }
      summary: Rename a passkey
      tags: [access]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 80 }
      responses:
        "200":
          description: Renamed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Passkey" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: removePasskey
      x-outflare-access: { signed_in: true }
      summary: Remove a passkey
      tags: [access]
      responses:
        "204": { description: Removed. It no longer signs you in. }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/auth/google/start:
    get:
      operationId: startGoogleSignIn
      summary: Start "Continue with Google"
      description: |
        Redirects to Google's sign-in screen (scopes openid, email, profile only). Open it in the browser, not with fetch.
        Needs GOOGLE_OAUTH_CLIENT_ID/SECRET, with `<PUBLIC_URL>/v1/auth/google/callback` added to the client's redirect URIs.
        Google then returns to the callback, which signs the person in to the account linked to that Google account, or the
        account with the same verified email. Nobody gets an account from Google alone: an unknown person is sent back to
        `return` with `identity` (valid 15 minutes), `identity_email` and `identity_name`, and the invite page redeems
        `identity` instead of a password. Errors come back as `login_error`.
      tags: [access]
      security: []
      parameters:
        - name: return
          in: query
          description: Path on this site to come back to, such as /invite#<token>. Default /.
          schema: { type: string }
      responses:
        "302": { description: To Google. }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/auth/google/callback:
    get:
      operationId: googleSignInCallback
      summary: Google's return from "Continue with Google"
      description: Called by Google, not by clients. Redirects to the app, or back to `return` with `identity` or `login_error`.
      tags: [access]
      security: []
      responses:
        "302": { description: Back to the app. }
        default: { $ref: "#/components/responses/Error" }
  /v1/invites/accept:
    post:
      operationId: acceptInvite
      summary: Accept a workspace invite
      description: |
        A new email creates an account (name and password required). An existing account must give its password, or be
        signed in already. Instead of a password, `identity` from "Continue with Google" proves the invited email; a new
        account made that way has no password and signs in with Google.
      tags: [access]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
                name: { type: string }
                password: { type: string }
                identity: { type: string, description: The identity parameter from the Google sign-in callback. }
      responses:
        "200": { description: Joined; a session cookie is set. }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/instance/telemetry:
    get:
      operationId: getTelemetry
      x-outflare-access: { signed_in: true }
      summary: Usage stats and update check for this server
      description: |
        Self-hosted servers send one anonymous report a day (version,
        platform, usage counts as ranges, connector ids) and get back the
        newest version and any security notices. See docs/telemetry.md.
        Needs a signed-in user.
      tags: [instance]
      responses:
        "200":
          description: Telemetry and update status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TelemetryStatus" }
        "403": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateTelemetry
      x-outflare-access: { signed_in: true }
      summary: Turn usage stats on or off
      description: Only the person who set up the server can change it. Refused with 409 when the server's environment (OUTFLARE_TELEMETRY, DO_NOT_TRACK) or the build decides.
      tags: [instance]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: { type: boolean }
      responses:
        "200":
          description: Updated status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TelemetryStatus" }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/instance/telemetry/preview:
    get:
      operationId: previewTelemetry
      x-outflare-access: { signed_in: true }
      summary: The exact report this server would send
      tags: [instance]
      responses:
        "200":
          description: The report, not sent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TelemetryReport" }
        "403": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/me:
    get:
      operationId: getMe
      x-outflare-access: { signed_in: true }
      summary: The signed-in user and their workspaces
      tags: [access]
      responses:
        "200":
          description: Current user.
          content:
            application/json:
              schema:
                type: object
                required: [user, workspaces, agencies]
                properties:
                  user:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      email: { type: string }
                      name: { type: string }
                      email_verified:
                        type: boolean
                        description: The person opened a confirmation or reset link sent to this address, or Google vouched for it.
                      has_password:
                        type: boolean
                        description: False for accounts made with Google, until they set a password with a reset link.
                  workspaces:
                    type: array
                    description: Workspaces the user is a member of, plus every client workspace of their agencies, each with their strongest role.
                    items: { $ref: "#/components/schemas/Workspace" }
                  agencies:
                    type: array
                    items: { $ref: "#/components/schemas/Agency" }
        default: { $ref: "#/components/responses/Error" }
  /v1/agencies:
    get:
      operationId: listAgencies
      x-outflare-access: { signed_in: true }
      summary: Agencies the signed-in user belongs to
      tags: [agencies]
      responses:
        "200":
          description: Agencies.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Agency" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createAgency
      x-outflare-access: { signed_in: true }
      summary: Create an agency
      description: |
        Signed-in users only. The creator becomes the agency's owner. Pass
        workspace_ids to bring workspaces you own in as clients.
      tags: [agencies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 100 }
                workspace_ids: { type: array, items: { type: string, format: uuid } }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Agency" }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/agencies/{ag}:
    parameters: [{ $ref: "#/components/parameters/Agency" }]
    get:
      operationId: getAgency
      x-outflare-access: { agency_role: viewer, agency_tokens: true }
      summary: An agency
      description: Agency members, or the agency's own tokens.
      tags: [agencies]
      responses:
        "200":
          description: The agency.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Agency" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateAgency
      x-outflare-access: { agency_role: admin }
      summary: Rename the agency or change its white label
      description: Agency admins. Client members see the brand name, logo and accent colour in place of Outflare.
      tags: [agencies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 100 }
                brand_name: { type: string, maxLength: 60 }
                logo_url: { type: string, description: An https URL, or empty to clear. }
                accent_color: { type: string, pattern: "^(#[0-9a-fA-F]{6})?$" }
                support_email: { type: string }
      responses:
        "200":
          description: The agency.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Agency" }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/agencies/{ag}/clients:
    parameters: [{ $ref: "#/components/parameters/Agency" }]
    get:
      operationId: listAgencyClients
      x-outflare-access: { agency_role: viewer, agency_tokens: true }
      summary: Every client with this week's numbers
      description: Agency members or agency tokens. Worst bounce rate first.
      tags: [agencies]
      responses:
        "200":
          description: Clients.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/AgencyClient" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: addAgencyClient
      x-outflare-access: { agency_role: admin, agency_tokens: true }
      summary: Create a client workspace, or bring in one you own
      description: |
        Send name to create a new client workspace (agency admins, or agency
        tokens with can_create_clients). It has no direct members: agency
        people reach it through their agency role. Send workspace_id to bring
        in a workspace you own directly.
      tags: [agencies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 100 }
                workspace_id: { type: string, format: uuid }
      responses:
        "201":
          description: The client workspace.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Workspace" }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/agencies/{ag}/clients/{ws}:
    parameters:
      - { $ref: "#/components/parameters/Agency" }
      - { $ref: "#/components/parameters/Workspace" }
    delete:
      operationId: removeAgencyClient
      x-outflare-access: { agency_role: owner }
      summary: Take a workspace out of the agency
      description: Agency owners. The workspace and its data stay. If nobody owns it directly, you become its owner.
      tags: [agencies]
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/agencies/{ag}/members:
    parameters: [{ $ref: "#/components/parameters/Agency" }]
    get:
      operationId: listAgencyMembers
      x-outflare-access: { agency_role: viewer }
      summary: Agency members
      tags: [agencies]
      responses:
        "200":
          description: Members.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Member" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: addAgencyMember
      x-outflare-access: { agency_role: admin }
      summary: Add someone who already has an account
      description: Agency admins. They get this role in every client workspace. Nobody can give a role above their own.
      tags: [agencies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string }
                role: { $ref: "#/components/schemas/Role" }
      responses:
        "200":
          description: Members.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Member" } }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/agencies/{ag}/members/{user}:
    parameters:
      - { $ref: "#/components/parameters/Agency" }
      - { name: user, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      operationId: updateAgencyMember
      x-outflare-access: { agency_role: admin }
      summary: Change an agency member's role
      tags: [agencies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { $ref: "#/components/schemas/Role" }
      responses:
        "200":
          description: Members.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Member" } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: removeAgencyMember
      x-outflare-access: { agency_role: admin }
      summary: Remove someone from the agency
      description: The last owner can't be removed.
      tags: [agencies]
      responses:
        "204": { description: Removed. }
        default: { $ref: "#/components/responses/Error" }
  /v1/agencies/{ag}/api-tokens:
    parameters: [{ $ref: "#/components/parameters/Agency" }]
    get:
      operationId: listAgencyTokens
      x-outflare-access: { agency_role: admin }
      summary: Active agency tokens
      tags: [agencies]
      responses:
        "200":
          description: Tokens.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/AgencyToken" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createAgencyToken
      x-outflare-access: { agency_role: admin }
      summary: Create a token that works in every client workspace
      description: Agency admins. Scopes apply in each client and can't exceed your agency role. The secret is returned once.
      tags: [agencies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scopes]
              properties:
                name: { type: string }
                scopes: { type: array, items: { $ref: "#/components/schemas/Scope" } }
                can_create_clients: { type: boolean, default: false }
                expires_in_days: { type: integer, minimum: 1, maximum: 3650 }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_token: { $ref: "#/components/schemas/AgencyToken" }
                  token: { type: string, description: "The secret, starting ofa_. Shown once." }
        default: { $ref: "#/components/responses/Error" }
  /v1/agencies/{ag}/api-tokens/{id}:
    parameters:
      - { $ref: "#/components/parameters/Agency" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      operationId: revokeAgencyToken
      x-outflare-access: { agency_role: admin }
      summary: Revoke an agency token
      tags: [agencies]
      responses:
        "204": { description: Revoked. }
        default: { $ref: "#/components/responses/Error" }
  /v1/agencies/{ag}/events:
    parameters: [{ $ref: "#/components/parameters/Agency" }]
    get:
      operationId: listAgencyEvents
      x-outflare-access: { agency_role: admin }
      summary: Agency log
      description: Agency admins. Clients, members, tokens and branding changes, newest first (last 200).
      tags: [agencies]
      responses:
        "200":
          description: Events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/AgencyEvent" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces:
    post:
      operationId: createWorkspace
      x-outflare-access: { signed_in: true }
      summary: Create a workspace owned by the signed-in user
      tags: [access]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Workspace" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: getWorkspace
      x-outflare-access: { scope: workspace.read }
      summary: Get a workspace
      description: "Scope: workspace.read"
      tags: [access]
      responses:
        "200":
          description: The workspace.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Workspace" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/members:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listMembers
      x-outflare-access: { scope: members.read }
      summary: List members
      description: "Scope: members.read"
      tags: [access]
      responses:
        "200":
          description: Members, oldest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Member" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/members/{user}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: user, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      operationId: updateMember
      x-outflare-access: { scope: members.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change a member's role
      description: "Scope: members.manage. Nobody can act above their own role; only owners can grant or change owner; the last owner cannot be demoted."
      tags: [access]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { $ref: "#/components/schemas/Role" }
      responses:
        "200": { description: Updated. }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: removeMember
      x-outflare-access: { scope: members.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Remove a member
      description: "Scope: members.manage"
      tags: [access]
      responses:
        "204": { description: Removed. }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/invites:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listInvites
      x-outflare-access: { scope: members.manage }
      summary: List pending invites
      description: "Scope: members.manage"
      tags: [access]
      responses:
        "200":
          description: Pending invites, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Invite" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createInvite
      x-outflare-access: { scope: members.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Invite someone by email
      description: "Scope: members.manage. The token and link are returned once; they expire after 7 days."
      tags: [access]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [admin, operator, viewer] }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  invite: { $ref: "#/components/schemas/Invite" }
                  token: { type: string }
                  invite_url: { type: string }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/invites/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      operationId: revokeInvite
      x-outflare-access: { scope: members.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Revoke a pending invite
      description: "Scope: members.manage"
      tags: [access]
      responses:
        "204": { description: Revoked. }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/api-tokens:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listAPITokens
      x-outflare-access: { scope: tokens.manage }
      summary: List active API tokens
      description: "Scope: tokens.manage. Secrets are never returned after creation."
      tags: [access]
      responses:
        "200":
          description: Tokens, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/APIToken" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createAPIToken
      x-outflare-access: { scope: tokens.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Create a scoped API token
      description: "Scope: tokens.manage. A token cannot hold scopes or looser limits than its creator. The secret is returned once."
      tags: [access]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scopes]
              properties:
                name: { type: string }
                scopes:
                  type: array
                  items: { $ref: "#/components/schemas/Scope" }
                expires_in_days: { type: integer, minimum: 1, maximum: 3650 }
                daily_send_budget: { type: [integer, "null"], minimum: 1, maximum: 1000000 }
                max_priority: { type: [string, "null"], enum: [low, normal, high, null] }
                may_take_mailboxes: { type: boolean }
                default_team_id: { type: [string, "null"], format: uuid }
                project_id: { type: [string, "null"], format: uuid, description: "Binds the token to one project: it sees and changes only that project's records, and endpoints that are not project-aware refuse it. Fixed for the token's life." }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_token: { $ref: "#/components/schemas/APIToken" }
                  token: { type: string, description: "The secret, starting oft_. Shown once." }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/api-tokens/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      operationId: updateAPITokenLimits
      x-outflare-access: { scope: tokens.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change a token's sending and allocation limits
      description: "Scope: tokens.manage. A token cannot change its own limits, or grant looser ones than it has. Scopes are fixed; issue a new token to change them."
      tags: [access]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/APITokenLimits" }
      responses:
        "200":
          description: The token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIToken" }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: revokeAPIToken
      x-outflare-access: { scope: tokens.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Revoke an API token
      description: "Scope: tokens.manage"
      tags: [access]
      responses:
        "204": { description: Revoked. }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/audit-events:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listAuditEvents
      x-outflare-access: { scope: audit.read }
      summary: Audit log, newest first
      description: "Scope: audit.read. Page with before=next_before."
      tags: [access]
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: before, in: query, schema: { type: integer } }
      responses:
        "200":
          description: Events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/AuditEvent" }
                  next_before: { type: [integer, "null"] }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listMailboxes
      x-outflare-access: { scope: mailboxes.read, project_tokens: true }
      summary: List mailboxes
      description: "Scope: mailboxes.read. Credentials are never returned."
      tags: [mailboxes]
      parameters:
        - { name: status, in: query, schema: { $ref: "#/components/schemas/MailboxStatus" } }
        - { name: stage, in: query, schema: { $ref: "#/components/schemas/LifecycleStage" } }
        - { name: pool_id, in: query, schema: { type: string, format: uuid } }
        - { name: domain, in: query, schema: { type: string } }
        - { name: server_id, in: query, schema: { type: string, format: uuid } }
        - { name: project_id, in: query, description: "A project's id, or none for records shared by the workspace. A project token always sees only its project.", schema: { type: string } }
      responses:
        "200":
          description: Mailboxes, ordered by email.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Mailbox" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: addMailbox
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Add or update one mailbox
      description: |
        Scope: mailboxes.manage. Upserts by email. Google Workspace and
        Microsoft 365 are detected from MX records; other providers need the
        host and port fields. The mailbox starts as `verifying` while a job
        signs in over SMTP and IMAP.
      tags: [mailboxes]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MailboxInput" }
      responses:
        "200":
          description: Updated an existing mailbox.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Mailbox" }
        "201":
          description: Added.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Mailbox" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes/import:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: importMailboxes
      x-outflare-access: { scope: mailboxes.manage }
      summary: Bulk import mailboxes from CSV
      description: |
        Scope: mailboxes.manage. Send the CSV file as the body (up to 5 MB and
        5000 rows). Required columns: `email` and `password` (or
        `smtp_password`). Optional: `name` (or `first_name` and `last_name`),
        `provider`, `smtp_host`, `smtp_port`, `smtp_username`,
        `smtp_password`, `imap_host`, `imap_port`, `imap_username`,
        `imap_password`, `pool` and `daily_limit`. Common reseller and
        sequencer headers such as `Email Address` and `App Password` are
        recognised. Rows are upserted by email; bad rows are reported by line
        and the rest still import.
      tags: [mailboxes]
      parameters:
        - name: pool
          in: query
          description: Pool name for rows that do not name one. Created if missing.
          schema: { type: string }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          text/csv:
            schema: { type: string }
      responses:
        "200":
          description: Import summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportResult" }
        "400": { $ref: "#/components/responses/Error" }
        "413": { $ref: "#/components/responses/Error" }
        "415": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getMailbox
      x-outflare-access: { scope: mailboxes.read, project_tokens: true }
      summary: Get a mailbox
      description: "Scope: mailboxes.read"
      tags: [mailboxes]
      responses:
        "200":
          description: The mailbox.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Mailbox" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateMailbox
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change a mailbox's name, limits, pool, or pause and resume it
      description: |
        Scope: mailboxes.manage. Set `status` to `paused` to stop sending, or
        to `active` to resume; resuming re-verifies the connection first.
        Set `pool_id` to null to remove the mailbox from its pool.
      tags: [mailboxes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name: { type: string }
                daily_limit: { type: integer, minimum: 1, maximum: 500 }
                min_gap_seconds: { type: integer, minimum: 0, maximum: 3600 }
                pool_id: { type: [string, "null"], format: uuid }
                status: { type: string, enum: [paused, active] }
                warmup: { $ref: "#/components/schemas/WarmupSettings" }
      responses:
        "200":
          description: The updated mailbox.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Mailbox" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteMailbox
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Remove a mailbox and its stored credentials
      description: "Scope: mailboxes.manage"
      tags: [mailboxes]
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes/{id}/verify:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: verifyMailbox
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Re-check a mailbox's SMTP and IMAP sign-in and its domain's DNS
      description: "Scope: mailboxes.manage. Runs in the background; poll the mailbox for the result."
      tags: [mailboxes]
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ $ref: "#/components/schemas/Queued" }, { $ref: "#/components/schemas/MissionHeld" }] }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes/{id}/warmup:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getMailboxWarmup
      x-outflare-access: { scope: mailboxes.read }
      summary: A mailbox's warm-up results for each of the last 14 days
      description: "Scope: mailboxes.read. Days are UTC, oldest first."
      tags: [mailboxes]
      responses:
        "200":
          description: Daily results.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WarmupDay" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes/warmup:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: updateWarmup
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Turn warm-up on or off, or change its settings, for many mailboxes
      description: |
        Scope: mailboxes.manage. Applies to the mailboxes in `mailbox_ids`,
        or to every mailbox in `pool_id`, or to every mailbox in the
        workspace when neither is given. Warming mailboxes email each other,
        so turn it on for at least two, ideally on different domains.
        Turning warm-up on starts the ramp from its first day: 2 emails a
        day, then 2 more each day up to `daily_target`.
      tags: [mailboxes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/WarmupSettings" }
                - type: object
                  properties:
                    mailbox_ids: { type: array, items: { type: string, format: uuid } }
                    pool_id: { type: string, format: uuid }
      responses:
        "200":
          description: How many mailboxes changed.
          content:
            application/json:
              schema:
                type: object
                required: [updated]
                properties:
                  updated: { type: integer }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailbox-pools:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listMailboxPools
      x-outflare-access: { scope: mailboxes.read }
      summary: List mailbox pools
      description: "Scope: mailboxes.read"
      tags: [mailboxes]
      responses:
        "200":
          description: Pools.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/MailboxPool" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createMailboxPool
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Create a mailbox pool
      description: "Scope: mailboxes.manage"
      tags: [mailboxes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 100 }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MailboxPool" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailbox-pools/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      operationId: deleteMailboxPool
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Delete a pool. Its mailboxes stay, without a pool.
      description: "Scope: mailboxes.manage"
      tags: [mailboxes]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sending-domains:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listSendingDomains
      x-outflare-access: { scope: mailboxes.read, project_tokens: true }
      summary: List sending domains with health, settings and sending stats
      description: |
        Scope: mailboxes.read. Every domain your mailboxes send from, plus
        domains added by hand. Each carries its DNS (MX, SPF, DKIM, DMARC)
        and blocklist results, the issues to fix, its settings, mailbox
        counts and sends, replies and bounces over rolling windows.
      tags: [domains]
      parameters:
        - { name: project_id, in: query, description: "A project's id, or none for records shared by the workspace. A project token always sees only its project.", schema: { type: string } }
      responses:
        "200":
          description: Domains, sorted by name.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/SendingDomain" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: addSendingDomain
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Add a domain to track before it has mailboxes
      description: |
        Scope: mailboxes.manage. Accepts `acme.com`, `@acme.com`, a URL or an
        address on the domain. Its DNS check runs in the background; poll the
        domain until `checked_at` is set. Domains of connected mailboxes are
        added on their own.
      tags: [domains]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string, example: acme.com }
      responses:
        "201":
          description: Added.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendingDomain" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sending-domains/check:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: checkAllSendingDomains
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Re-check DNS and blocklists for every domain
      description: "Scope: mailboxes.manage. Runs in the background. Every domain is also re-checked on its own once a day."
      tags: [domains]
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ type: object, required: [queued], properties: { queued: { type: integer, description: "Domains queued for a check." } } }, { $ref: "#/components/schemas/MissionHeld" }] }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sending-domains/{domain}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { $ref: "#/components/parameters/Domain" }
    get:
      operationId: getSendingDomain
      x-outflare-access: { scope: mailboxes.read, project_tokens: true }
      summary: Get a domain, with daily sends, replies and bounces for 14 days
      description: "Scope: mailboxes.read. List the domain's mailboxes with `GET /mailboxes?domain=`."
      tags: [domains]
      responses:
        "200":
          description: The domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendingDomainDetail" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateSendingDomain
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Pause or resume a domain, cap its daily sends, or set limits on all its mailboxes
      description: |
        Scope: mailboxes.manage. `status: paused` stops sequence and test
        sends from every mailbox on the domain (replies written in the inbox
        still go); `active` resumes. `daily_limit` caps sends from all of the
        domain's mailboxes together in any 24 hours; null removes the cap.
        `mailbox_daily_limit` and `mailbox_min_gap_seconds` set those
        settings on every mailbox on the domain at once.
      tags: [domains]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { type: string, enum: [active, paused] }
                daily_limit: { type: [integer, "null"], minimum: 1, maximum: 100000 }
                notes: { type: string, maxLength: 2000 }
                mailbox_daily_limit: { type: integer, minimum: 1, maximum: 500 }
                mailbox_min_gap_seconds: { type: integer, minimum: 0, maximum: 3600 }
      responses:
        "200":
          description: The updated domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendingDomain" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteSendingDomain
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Stop tracking a domain that has no mailboxes
      description: "Scope: mailboxes.manage. Returns 409 while mailboxes on the domain are connected."
      tags: [domains]
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sending-domains/{domain}/check:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { $ref: "#/components/parameters/Domain" }
    post:
      operationId: checkSendingDomain
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Re-check a domain's DNS and blocklists now
      description: "Scope: mailboxes.manage. Runs in the background; poll the domain for a new `checked_at`. Use it after changing DNS records."
      tags: [domains]
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ $ref: "#/components/schemas/Queued" }, { $ref: "#/components/schemas/MissionHeld" }] }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sending-servers:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listSendingServers
      x-outflare-access: { scope: mailboxes.read }
      summary: List sending servers with IPs, blocklist results, settings and sending stats
      description: |
        Scope: mailboxes.read. A sending server is the SMTP host a group of
        mailboxes signs in to, and the IP addresses they share: a cold-email
        SMTP vendor's server (Mailreef, Infraforge) or your own mail server.
        Mailboxes outside Google and Microsoft join one on their own when
        they are connected, grouped by `smtp_host`. Each server carries its
        IPs, their blocklist results, the issues to fix, a shared daily
        limit and pause, mailbox counts and sends over rolling windows.
      tags: [servers]
      responses:
        "200":
          description: Servers, sorted by host.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/SendingServer" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sending-servers/check:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: checkAllSendingServers
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Re-check IPs and blocklists for every server
      description: "Scope: mailboxes.manage. Runs in the background. Every server is also re-checked on its own once a day."
      tags: [servers]
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ type: object, required: [queued], properties: { queued: { type: integer, description: "Servers queued for a check." } } }, { $ref: "#/components/schemas/MissionHeld" }] }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sending-servers/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getSendingServer
      x-outflare-access: { scope: mailboxes.read }
      summary: Get a sending server
      description: "Scope: mailboxes.read. List its mailboxes with `GET /mailboxes?server_id=`."
      tags: [servers]
      responses:
        "200":
          description: The server.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendingServer" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateSendingServer
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Pause or resume a server, cap its daily sends, or set its sending IPs
      description: |
        Scope: mailboxes.manage. `status: paused` stops sequence and test
        sends from every mailbox on the server (replies written in the inbox
        still go); `active` resumes, including after an automatic blocklist
        pause. `daily_limit` caps sends from all its mailboxes together in
        any 24 hours; null removes the cap. `pause_on_listing` (on by
        default) pauses the server when a check finds one of its IPs on a
        blocklist, and resumes it when every IP is clear again; a pause by a
        person is never lifted automatically. `ips` sets the sending
        addresses by hand, for servers that accept mail on one address and
        send from others; null goes back to the host's DNS records. Changing
        `ips` queues a new check.
      tags: [servers]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 200 }
                status: { type: string, enum: [active, paused] }
                daily_limit: { type: [integer, "null"], minimum: 1, maximum: 1000000 }
                notes: { type: string, maxLength: 2000 }
                pause_on_listing: { type: boolean }
                ips:
                  type: [array, "null"]
                  minItems: 1
                  maxItems: 256
                  items: { type: string, description: An IPv4 or IPv6 address. }
      responses:
        "200":
          description: The updated server.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendingServer" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteSendingServer
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Remove a server that no mailbox uses
      description: "Scope: mailboxes.manage. Returns 409 while mailboxes still sign in to it."
      tags: [servers]
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sending-servers/{id}/check:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: checkSendingServer
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Re-check a server's IPs and blocklists now
      description: "Scope: mailboxes.manage. Runs in the background; poll the server for a new `checked_at`. Use it after a delisting request."
      tags: [servers]
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ $ref: "#/components/schemas/Queued" }, { $ref: "#/components/schemas/MissionHeld" }] }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/tracking-domains:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listTrackingDomains
      x-outflare-access: { scope: mailboxes.read }
      summary: List tracking domains
      description: |
        Scope: mailboxes.read. Optional. Once a domain is verified, the
        workspace's unsubscribe links and any click-tracked links use it
        instead of this server's address. `target` is what the CNAME must
        point to.
      tags: [tracking]
      responses:
        "200":
          description: Tracking domains.
          content:
            application/json:
              schema:
                type: object
                required: [data, target]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TrackingDomain" }
                  target: { type: string }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: addTrackingDomain
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Add a tracking domain
      description: "Scope: mailboxes.manage. Add a CNAME record from the domain to `target` first or after; the DNS is checked straight away and whenever you call check."
      tags: [tracking]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string, example: links.acme.com }
      responses:
        "201":
          description: The domain, with the result of the first check.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TrackingDomain" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/tracking-domains/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      operationId: removeTrackingDomain
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Remove a tracking domain
      description: "Scope: mailboxes.manage. Links already sent keep working only while the domain still points here."
      tags: [tracking]
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/tracking-domains/{id}/check:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: checkTrackingDomain
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Check a tracking domain's DNS now
      description: "Scope: mailboxes.manage."
      tags: [tracking]
      responses:
        "200":
          description: The domain, with the result of the check.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TrackingDomain" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/leads:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listLeads
      x-outflare-access: { scope: leads.read }
      summary: List and search leads, ordered by email
      description: "Scope: leads.read"
      tags: [leads]
      parameters:
        - { name: list_id, in: query, schema: { type: string, format: uuid } }
        - { name: q, in: query, description: "Matches email, name or company.", schema: { type: string } }
        - { name: suppressed, in: query, schema: { type: boolean } }
        - { name: verification, in: query, description: "Latest verification result, or unverified for never checked.", schema: { type: string, enum: [deliverable, undeliverable, risky, unknown, unverified] } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
        - { name: after, in: query, description: "next_after from the previous page.", schema: { type: string } }
      responses:
        "200":
          description: Leads.
          content:
            application/json:
              schema:
                type: object
                required: [data, total, next_after]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Lead" }
                  total: { type: integer, description: Matches across all pages. }
                  next_after: { type: [string, "null"], description: Pass as after to get the next page. }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: upsertLead
      x-outflare-access: { scope: leads.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Add a lead, or update the lead with this email
      description: "Scope: leads.manage. Empty fields keep stored values; custom fields are merged."
      tags: [leads]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LeadInput" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Lead" }
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Lead" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/leads/import:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: importLeads
      x-outflare-access: { scope: leads.manage }
      summary: Bulk import leads from CSV
      description: |
        Scope: leads.manage. Send the CSV file as the body (up to 50 MB and
        100,000 rows). Only an email column is required. Standard columns are
        recognised under common export names (first name, last name, full
        name, company, title, phone, LinkedIn URL, website); every other
        column is kept as a custom field under its snake_case header, for use
        in templates. Leads are upserted by email: a row for an existing lead
        fills in non-empty fields and merges custom fields. Rows repeating an
        email within the file are merged. Suppressed people are imported but
        counted, and are never emailed.
      tags: [leads]
      parameters:
        - name: list
          in: query
          description: Add every imported lead to this list, created if missing.
          schema: { type: string, maxLength: 100 }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          text/csv:
            schema: { type: string }
      responses:
        "200":
          description: Import summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LeadImportResult" }
        "400": { $ref: "#/components/responses/Error" }
        "413": { $ref: "#/components/responses/Error" }
        "415": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/leads/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getLead
      x-outflare-access: { scope: leads.read }
      summary: Get a lead
      description: "Scope: leads.read"
      tags: [leads]
      responses:
        "200":
          description: The lead.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Lead" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateLead
      x-outflare-access: { scope: leads.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change a lead's fields
      description: "Scope: leads.manage. In custom, a null value removes that field."
      tags: [leads]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name: { type: string }
                last_name: { type: string }
                company: { type: string }
                title: { type: string }
                phone: { type: string }
                linkedin_url: { type: string }
                website: { type: string }
                custom:
                  type: object
                  additionalProperties: { type: [string, "null"] }
      responses:
        "200":
          description: The updated lead.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Lead" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteLead
      x-outflare-access: { scope: leads.manage }
      summary: Delete a lead
      description: |
        Scope: leads.manage. With suppress=true the address is also added to
        the suppression list (reason do_not_contact), so an erasure request is
        honoured even if the person is imported again later.
      tags: [leads]
      parameters:
        - { name: suppress, in: query, schema: { type: boolean, default: false } }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/lead-lists:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listLeadLists
      x-outflare-access: { scope: leads.read, project_tokens: true }
      summary: List lead lists, with each list's latest run
      description: "Scope: leads.read. A project token sees only its project's lists."
      tags: [lists]
      parameters:
        - { name: project_id, in: query, description: "A project's id, or none for lists shared by the whole workspace.", schema: { type: string } }
        - { name: kind, in: query, schema: { type: string, enum: [manual, search] } }
      responses:
        "200":
          description: Lists.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/LeadList" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createLeadList
      x-outflare-access: { scope: leads.manage, project_tokens: true }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Create a lead list, optionally built from a data provider search
      description: >-
        Scope: leads.manage. A list with search_integration_id and filters is a
        search list that can be built and refreshed from that provider. Set
        build to true to queue the first build in the same call. A project
        token's lists always belong to its project.
      tags: [lists]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/LeadListInput"
                - type: object
                  required: [name]
                  properties:
                    build: { type: boolean, default: false, description: Queue the first build now. }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LeadList" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/lead-lists/preview:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: previewLeadList
      x-outflare-access: { scope: leads.manage, project_tokens: true }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Preview a search before building a list from it
      description: >-
        Scope: leads.manage. Queues a run that fetches one page (10 people) from
        the provider and saves nothing. Poll GET /list-runs/{run} for the
        sample and the total number of matches. No emails are revealed; some
        providers (People Data Labs) charge for search results.
      tags: [lists]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [search_integration_id, filters]
              properties:
                search_integration_id: { type: string, format: uuid }
                filters: { $ref: "#/components/schemas/PeopleFilter" }
                project_id: { type: [string, "null"], format: uuid, description: Score the sample against this project's ICP. }
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ $ref: "#/components/schemas/ListRun" }, { $ref: "#/components/schemas/MissionHeld" }] }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/lead-lists/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getLeadList
      x-outflare-access: { scope: leads.read, project_tokens: true }
      summary: Get a lead list and its latest run
      description: "Scope: leads.read"
      tags: [lists]
      responses:
        "200":
          description: The list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LeadList" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateLeadList
      x-outflare-access: { scope: leads.manage, project_tokens: true }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change a list's settings
      description: "Scope: leads.manage. Absent fields keep their value; filters are replaced as a whole."
      tags: [lists]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LeadListInput" }
      responses:
        "200":
          description: The list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LeadList" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteLeadList
      x-outflare-access: { scope: leads.manage, project_tokens: true }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Delete a list. Its leads stay.
      description: "Scope: leads.manage"
      tags: [lists]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/lead-lists/{id}/members:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: listLeadListMembers
      x-outflare-access: { scope: leads.read, project_tokens: true }
      summary: List a list's leads with their ICP fit scores
      description: "Scope: leads.read. Sorted by score, best first, unless sort says otherwise."
      tags: [lists]
      parameters:
        - { name: sort, in: query, schema: { type: string, enum: [score, added, email], default: score } }
        - { name: q, in: query, description: "Search email, name, company and title.", schema: { type: string } }
        - { name: min_score, in: query, schema: { type: integer, minimum: 0, maximum: 100 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        "200":
          description: Members.
          content:
            application/json:
              schema:
                type: object
                required: [data, total, next_offset]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/LeadListMember" }
                  total: { type: integer }
                  next_offset: { type: [integer, "null"] }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/lead-lists/{id}/leads:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: addLeadsToList
      x-outflare-access: { scope: leads.manage, project_tokens: true }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Add leads to a list
      description: "Scope: leads.manage. Ids of leads outside the workspace are ignored."
      tags: [lists]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lead_ids]
              properties:
                lead_ids:
                  type: array
                  maxItems: 10000
                  items: { type: string, format: uuid }
      responses:
        "200":
          description: How many were added.
          content:
            application/json:
              schema:
                type: object
                required: [added]
                properties:
                  added: { type: integer }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/lead-lists/{id}/leads/{lead}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: lead, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      operationId: removeLeadFromList
      x-outflare-access: { scope: leads.manage, project_tokens: true }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Remove a lead from a list
      description: "Scope: leads.manage"
      tags: [lists]
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/lead-lists/{id}/runs:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: listListRuns
      x-outflare-access: { scope: leads.read, project_tokens: true }
      summary: List a list's runs, newest first
      description: "Scope: leads.read"
      tags: [lists]
      responses:
        "200":
          description: Runs.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ListRun" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: startListRun
      x-outflare-access: { scope: leads.manage, project_tokens: true }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Build, refresh or enrich a list
      description: >-
        Scope: leads.manage. Spends provider credits. build and refresh search
        the provider, find missing emails through the waterfall and add new
        leads; enrich fills in missing fields on the list's leads. One run at a
        time per list (409 while one is queued or running). max_leads and
        credit_cap override the list's limits for this run only.
      tags: [lists]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                kind: { type: string, enum: [build, refresh, enrich], default: build }
                max_leads: { type: integer, minimum: 1, maximum: 50000 }
                credit_cap: { type: number, minimum: 0 }
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ $ref: "#/components/schemas/ListRun" }, { $ref: "#/components/schemas/MissionHeld" }] }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/list-runs/{run}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: run, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getListRun
      x-outflare-access: { scope: leads.read, project_tokens: true }
      summary: Get a run's progress or result
      description: "Scope: leads.read"
      tags: [lists]
      responses:
        "200":
          description: The run.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListRun" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/list-runs/{run}/cancel:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: run, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: cancelListRun
      x-outflare-access: { scope: leads.manage, project_tokens: true }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Stop a queued or running run. Leads already added stay.
      description: "Scope: leads.manage"
      tags: [lists]
      responses:
        "200":
          description: The run.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListRun" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/data-providers:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listDataProviders
      x-outflare-access: { scope: leads.read, project_tokens: true }
      summary: Data providers that can search for people, find emails or enrich
      description: >-
        Scope: leads.read. Every data connector, available or planned, with the
        workspace's connected accounts for each and the filters its search
        understands. Connect one with POST /integrations.
      tags: [lists]
      responses:
        "200":
          description: Providers and the shared filter vocabulary.
          content:
            application/json:
              schema:
                type: object
                required: [data, filters, seniorities, employee_ranges]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/DataProvider" }
                  filters: { type: array, items: { type: string } }
                  seniorities: { type: array, items: { type: string } }
                  employee_ranges: { type: array, items: { type: string } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/leads/{id}/sources:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: listLeadSources
      x-outflare-access: { scope: leads.read }
      summary: Where a lead's data came from
      description: "Scope: leads.read. The providers that supplied this lead, their id for the person, and the list run that added it."
      tags: [leads]
      responses:
        "200":
          description: Sources.
          content:
            application/json:
              schema:
                type: object
                required: [data, imported_as]
                properties:
                  imported_as: { type: string, description: "The lead's source: csv, api, or a provider id." }
                  data:
                    type: array
                    items:
                      type: object
                      required: [provider, external_id, integration_id, list_id, run_id, first_seen_at, last_seen_at]
                      properties:
                        provider: { type: string }
                        external_id: { type: string }
                        integration_id: { type: [string, "null"], format: uuid }
                        list_id: { type: [string, "null"], format: uuid }
                        run_id: { type: [string, "null"], format: uuid }
                        first_seen_at: { type: string, format: date-time }
                        last_seen_at: { type: string, format: date-time }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/suppressions:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listSuppressions
      x-outflare-access: { scope: leads.read }
      summary: List the suppression list, ordered by value
      description: "Scope: leads.read"
      tags: [suppression]
      parameters:
        - { name: kind, in: query, schema: { type: string, enum: [email, domain] } }
        - { name: reason, in: query, schema: { $ref: "#/components/schemas/SuppressionReason" } }
        - { name: q, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
        - { name: after, in: query, description: "next_after from the previous page.", schema: { type: string } }
      responses:
        "200":
          description: Entries.
          content:
            application/json:
              schema:
                type: object
                required: [data, total, next_after]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Suppression" }
                  total: { type: integer, description: Matches across all pages. }
                  next_after: { type: [string, "null"], description: Pass as after to get the next page. }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: addSuppressions
      x-outflare-access: { scope: leads.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Add emails or domains to the suppression list
      description: |
        Scope: leads.manage. Each value is an email address or a domain
        (also accepted as @acme.com or a URL). A suppressed domain blocks
        every address at it. Existing entries are left as they are.
      tags: [suppression]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [values]
              properties:
                values:
                  type: array
                  maxItems: 100000
                  items: { type: string }
                reason: { $ref: "#/components/schemas/SuppressionReason" }
                note: { type: string, maxLength: 500 }
      responses:
        "200":
          description: Result.
          content:
            application/json:
              schema:
                type: object
                required: [added, existing, invalid]
                properties:
                  added: { type: integer }
                  existing: { type: integer }
                  invalid:
                    type: array
                    items:
                      type: object
                      properties:
                        value: { type: string }
                        error: { type: string }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/suppressions/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      operationId: removeSuppression
      x-outflare-access: { scope: suppressions.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Remove a suppression entry
      description: "Scope: suppressions.manage (owners and admins by default). Audited."
      tags: [suppression]
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/recipients/check:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: checkRecipients
      x-outflare-access: { scope: leads.read }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Ask the send policy whether addresses may be emailed
      description: |
        Scope: leads.read. Runs the same recipient checks every send goes
        through, without sending. Use it to filter before enrolling leads.
      tags: [suppression]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emails]
              properties:
                emails:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items: { type: string }
      responses:
        "200":
          description: One decision per email, in order.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RecipientDecision" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes/{id}/test-send:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: testSendMailbox
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Send one test email from a mailbox now
      description: |
        Scope: mailboxes.manage. Goes through the recipient check and the
        mailbox's status, daily limit and minimum gap (not the sending
        window) and counts towards the daily limit. Returns 409 when the
        policy blocks it and 502 when the SMTP server refuses it.
      tags: [sequences]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to: { type: string, format: email }
                subject: { type: string }
                body: { type: string }
      responses:
        "200":
          description: Sent.
          content:
            application/json:
              schema:
                type: object
                required: [message_id, status]
                properties:
                  message_id: { type: string, format: uuid }
                  status: { type: string, example: sent }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sequences:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listSequences
      x-outflare-access: { scope: sequences.read, project_tokens: true }
      summary: List sequences (archived ones are left out)
      description: "Scope: sequences.read"
      tags: [sequences]
      parameters:
        - { name: project_id, in: query, description: "A project's id, or none for records shared by the workspace. A project token always sees only its project.", schema: { type: string } }
      responses:
        "200":
          description: Sequences, newest first, with steps and enrollment counts.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Sequence" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createSequence
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Create a sequence
      description: |
        Scope: sequences.manage. A new sequence is a `draft`: it sends
        nothing until its status is set to `active`. Defaults: UTC,
        Monday to Friday, 09:00 to 17:00, no daily cap on new leads, stop on
        reply.
      tags: [sequences]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/SequenceSettings" }
                - type: object
                  required: [name]
                  properties:
                    steps:
                      type: array
                      maxItems: 20
                      items: { $ref: "#/components/schemas/StepInput" }
      responses:
        "201":
          description: The sequence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sequence" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sequences/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getSequence
      x-outflare-access: { scope: sequences.read, project_tokens: true }
      summary: Get a sequence
      description: "Scope: sequences.read"
      tags: [sequences]
      responses:
        "200":
          description: The sequence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sequence" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateSequence
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change a sequence's settings, or activate, pause or archive it
      description: |
        Scope: sequences.manage. Activating needs at least one step, and a
        daily_new_limit or target_daily (or mailboxes assigned by hand) so
        Outflare knows how many mailboxes to allocate; it allocates them
        straight away. Pausing holds every enrollment where it is, keeps the
        mailboxes with leads mid-sequence and frees the rest; activating
        again carries on.
      tags: [sequences]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/SequenceSettings" }
                - type: object
                  properties:
                    status: { type: string, enum: [active, paused, archived] }
      responses:
        "200":
          description: The updated sequence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sequence" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteSequence
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Delete a sequence that is not active
      description: "Scope: sequences.manage. Sent messages are kept in the message log."
      tags: [sequences]
      responses:
        "204": { description: Deleted. }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sequences/{id}/steps:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      operationId: replaceSequenceSteps
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Replace a sequence's steps
      description: |
        Scope: sequences.manage. The first step needs a subject. A later
        step with an empty subject replies in the same thread ("Re: ...").
        `delay_days` is the wait after the previous step (at least 1 after
        the first). Enrollments carry on from their current position.
      tags: [sequences]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [steps]
              properties:
                steps:
                  type: array
                  maxItems: 20
                  items: { $ref: "#/components/schemas/StepInput" }
      responses:
        "200":
          description: The sequence.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sequence" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sequences/{id}/preview:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: previewSequence
      x-outflare-access: { scope: sequences.read }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Render every step for one lead
      description: |
        Scope: sequences.read. Shows the subject and body each step would
        send, and any variables the lead is missing (a missing variable
        blocks the real send).
      tags: [sequences]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lead_id]
              properties:
                lead_id: { type: string, format: uuid }
      responses:
        "200":
          description: Rendered steps.
          content:
            application/json:
              schema:
                type: object
                required: [data, suppressed]
                properties:
                  suppressed: { type: boolean }
                  data:
                    type: array
                    items:
                      type: object
                      required: [position, subject, body, missing]
                      properties:
                        position: { type: integer }
                        subject: { type: string }
                        body: { type: string }
                        missing:
                          type: array
                          items: { type: string }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sequences/{id}/enrollments:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: listEnrollments
      x-outflare-access: { scope: sequences.read, project_tokens: true }
      summary: List the leads in a sequence, ordered by email
      description: "Scope: sequences.read"
      tags: [sequences]
      parameters:
        - { name: status, in: query, schema: { $ref: "#/components/schemas/EnrollmentStatus" } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
        - { name: after, in: query, description: "next_after from the previous page.", schema: { type: string } }
      responses:
        "200":
          description: Enrollments.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_after]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Enrollment" }
                  next_after: { type: [string, "null"] }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: enrollLeads
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Add leads to a sequence
      description: |
        Scope: sequences.manage. Send `lead_ids` or `list_id`. Suppressed
        leads are skipped, and so are leads active in another sequence
        unless `skip_active_elsewhere` is false. Adding a lead twice does
        nothing.
      tags: [sequences]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                lead_ids:
                  type: array
                  maxItems: 10000
                  items: { type: string, format: uuid }
                list_id: { type: string, format: uuid }
                skip_active_elsewhere: { type: boolean, default: true }
      responses:
        "200":
          description: Counts.
          content:
            application/json:
              schema:
                type: object
                required: [enrolled, already_enrolled, suppressed, active_elsewhere]
                properties:
                  enrolled: { type: integer }
                  already_enrolled: { type: integer }
                  suppressed: { type: integer }
                  active_elsewhere: { type: integer }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sequences/{id}/enrollments/{eid}/stop:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: eid, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: stopEnrollment
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Stop sending to one lead in a sequence
      description: "Scope: sequences.manage"
      tags: [sequences]
      responses:
        "204": { description: Stopped. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/messages:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listMessages
      x-outflare-access: { scope: sequences.read }
      summary: List sent, failed and blocked messages, newest first
      description: |
        Scope: sequences.read. Every send attempt is recorded, including the
        ones the policy blocked and why.
      tags: [sequences]
      parameters:
        - { name: sequence_id, in: query, schema: { type: string, format: uuid } }
        - { name: lead_id, in: query, schema: { type: string, format: uuid } }
        - { name: mailbox_id, in: query, schema: { type: string, format: uuid } }
        - { name: enrollment_id, in: query, schema: { type: string, format: uuid } }
        - { name: status, in: query, schema: { type: string, enum: [sending, sent, failed, blocked, unknown] } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: before, in: query, description: "next_before from the previous page.", schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Messages.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_before]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Message" }
                  next_before: { type: [string, "null"], format: date-time }
        default: { $ref: "#/components/responses/Error" }
  /u/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    get:
      operationId: unsubscribePage
      security: []
      summary: Unsubscribe page linked from every email
      description: |
        Shows a confirmation button. A GET never unsubscribes, because mail
        scanners follow links.
      tags: [sequences]
      responses:
        "200": { description: HTML page., content: { text/html: {} } }
        "404": { description: The link is not valid., content: { text/html: {} } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: unsubscribe
      security: []
      summary: Unsubscribe (the page's button and RFC 8058 one-click)
      description: |
        Adds the address to the suppression list (reason unsubscribed) and
        stops every active sequence for it in the workspace.
      tags: [sequences]
      responses:
        "200": { description: HTML confirmation., content: { text/html: {} } }
        "404": { description: The link is not valid., content: { text/html: {} } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes/{id}/sync:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: syncMailbox
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Check a mailbox for new mail now
      description: "Scope: mailboxes.manage. Active mailboxes are also synced automatically every two minutes."
      tags: [inbox]
      responses:
        "202":
          description: "Sync queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ $ref: "#/components/schemas/Queued" }, { $ref: "#/components/schemas/MissionHeld" }] }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/inbox:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listInbox
      x-outflare-access: { scope: inbox.read }
      summary: List received mail, newest first
      description: |
        Scope: inbox.read. By default only replies and auto-replies from
        leads are listed; pass `kind=all` to include bounce reports and
        other mail.
      tags: [inbox]
      parameters:
        - { name: kind, in: query, schema: { type: string, enum: [reply, auto_reply, bounce, other, all] } }
        - { name: unread, in: query, schema: { type: boolean } }
        - { name: mailbox_id, in: query, schema: { type: string, format: uuid } }
        - { name: lead_id, in: query, schema: { type: string, format: uuid } }
        - { name: sequence_id, in: query, schema: { type: string, format: uuid } }
        - { name: status, in: query, description: "open: not done and not snoozed. Default all.", schema: { type: string, enum: [open, snoozed, done, all] } }
        - { name: category, in: query, description: "A category, or none for replies nobody has sorted yet.", schema: { type: string, enum: [interested, meeting_booked, question, referral, not_now, not_interested, wrong_person, out_of_office, unsubscribe, none] } }
        - { name: assigned_to, in: query, description: "me (signed-in person only), none, or a user id.", schema: { type: string } }
        - { name: label, in: query, schema: { type: string } }
        - { name: q, in: query, description: Search sender and subject., schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: before, in: query, description: "next_before from the previous page.", schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Messages.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_before, unread_replies]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/InboundMessage" }
                  next_before: { type: [string, "null"], format: date-time }
                  unread_replies: { type: integer, description: "Unread replies not yet marked done, across the workspace." }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/inbox/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getInboundMessage
      x-outflare-access: { scope: inbox.read }
      summary: Get a received message and its conversation
      description: |
        Scope: inbox.read. `thread` holds everything sent to and received
        from the lead through that mailbox, oldest first.
      tags: [inbox]
      responses:
        "200":
          description: The message and thread.
          content:
            application/json:
              schema:
                type: object
                required: [message, thread]
                properties:
                  message: { $ref: "#/components/schemas/InboundMessage" }
                  thread:
                    type: array
                    items: { $ref: "#/components/schemas/ThreadEntry" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateInboundMessage
      x-outflare-access: { scope: inbox.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Triage a message
      description: |
        Scope: inbox.manage. Send only the fields to change. Mark it read,
        sort it into a category, assign it to a member, snooze it, mark it
        done or set its labels. Changes other than read are audited.
      tags: [inbox]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                read: { type: boolean }
                category: { type: [string, "null"], enum: [interested, meeting_booked, question, referral, not_now, not_interested, wrong_person, out_of_office, unsubscribe, null] }
                assigned_to: { type: [string, "null"], format: uuid, description: A workspace member's user id, or null to unassign. }
                snoozed_until: { type: [string, "null"], format: date-time, description: Must be in the future; null wakes it now. }
                done: { type: boolean }
                labels: { type: array, maxItems: 20, items: { type: string, minLength: 1, maxLength: 40 }, description: Replaces the labels. Lower-cased. }
      responses:
        "200":
          description: The message.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InboundMessage" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/inbox/{id}/reply:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: replyToInboundMessage
      x-outflare-access: { scope: inbox.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Reply from the mailbox the message arrived at, in the same thread
      description: |
        Scope: inbox.manage. Goes through the recipient check (a suppressed
        or opted-out address gets 409) and needs the mailbox to be active.
        It is not held to a sequence's sending window or the mailbox's
        pacing, but counts towards its daily limit.
      tags: [inbox]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 20000 }
      responses:
        "200":
          description: Sent.
          content:
            application/json:
              schema:
                type: object
                required: [message_id, status]
                properties:
                  message_id: { type: string, format: uuid }
                  status: { type: string, example: sent }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/oauth-connections:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listOAuthConnections
      x-outflare-access: { scope: mailboxes.read }
      summary: List admin OAuth connections
      description: "Scope: mailboxes.read. Secrets are never returned."
      tags: [mailboxes]
      responses:
        "200":
          description: Connections.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/OAuthConnection" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createOAuthConnection
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Add a Google service account or a Microsoft Entra app
      description: |
        Scope: mailboxes.manage. Admin-level access to every mailbox in an
        organisation, so mailboxes can be added without passwords.

        - Google: a service account key file with domain-wide delegation
          for the scope https://mail.google.com/.
        - Microsoft: an Entra app with the Exchange Online application
          permissions IMAP.AccessAsApp and SMTP.SendAsApp. The secret is
          checked by getting a token before it is saved.
      tags: [mailboxes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider]
              properties:
                provider: { type: string, enum: [google, microsoft] }
                name: { type: string, description: Defaults to the service account's email or the tenant id. }
                service_account:
                  description: "Google: the key file, as a JSON object or as its text."
                  oneOf: [{ type: object }, { type: string }]
                tenant_id: { type: string, description: "Microsoft: directory (tenant) id." }
                client_id: { type: string, description: "Microsoft: application (client) id." }
                client_secret: { type: string, description: "Microsoft: client secret value." }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthConnection" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/oauth-connections/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      operationId: deleteOAuthConnection
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Remove an OAuth connection
      description: "Scope: mailboxes.manage. Refused with 409 while mailboxes use it."
      tags: [mailboxes]
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/oauth-connections/{id}/mailboxes:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: addOAuthConnectionMailboxes
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Add mailboxes that sign in through a connection
      description: |
        Scope: mailboxes.manage. Up to 5000 at once. No passwords: each
        mailbox signs in to SMTP and IMAP with XOAUTH2 using a token from
        the connection. Mailboxes that already exist are switched to the
        connection. Each is verified in the background.
      tags: [mailboxes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mailboxes]
              properties:
                mailboxes:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items:
                    type: object
                    required: [email]
                    properties:
                      email: { type: string }
                      display_name: { type: string }
                pool: { type: string, description: Pool name. Created if missing. }
                daily_limit: { type: integer, minimum: 1, maximum: 500 }
      responses:
        "200":
          description: Result. Errors refer to positions in the list, from 1.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportResult" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/oauth/{provider}/authorize:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: provider, in: path, required: true, schema: { type: string, enum: [google, microsoft] } }
      - { name: pool, in: query, schema: { type: string }, description: Pool for the connected mailbox. }
    get:
      operationId: authorizeOAuth
      x-outflare-access: { scope: mailboxes.manage }
      summary: Connect one mailbox through the provider's consent screen
      description: |
        Scope: mailboxes.manage, signed-in users only (not API tokens).
        Open this in the browser: it redirects to Google or Microsoft, and
        after approval back to /v1/oauth/callback, which adds the mailbox
        and redirects to the web app with ?connected=<email> or
        ?oauth_error=<reason>. Needs the server's OAuth app to be
        configured; see oauth_providers in /v1/meta.
      tags: [mailboxes]
      responses:
        "302": { description: Redirect to the consent screen. }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/oauth/callback:
    get:
      operationId: oauthCallback
      security: []
      summary: Where the provider returns after the consent screen
      description: Not called directly. Checks the signed state and that the same user is signed in.
      tags: [mailboxes]
      parameters:
        - { name: state, in: query, required: true, schema: { type: string } }
        - { name: code, in: query, schema: { type: string } }
        - { name: error, in: query, schema: { type: string } }
      responses:
        "302": { description: Redirect to the web app with the outcome. }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/integrations/{id}/mailboxes:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: "A mailbox supplier integration, such as Cheap Inboxes." }
      - { name: import_state, in: query, schema: { type: string, enum: [available, queued, waiting, app_password, imported, failed] } }
      - { name: status, in: query, schema: { type: string, enum: [provisioning, active, cancelling, cancelled, failed] }, description: Status at the supplier. }
      - { name: q, in: query, schema: { type: string }, description: Part of the email address. }
      - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
      - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
    get:
      operationId: listSupplierMailboxes
      x-outflare-access: { scope: mailboxes.read }
      summary: List the mailboxes held at a supplier
      description: |
        Scope: mailboxes.read. Every mailbox the connected supplier account
        holds, imported or not, as of the last sync (every 15 minutes, and
        at once on the supplier's webhooks). The first call for an
        integration connected before syncing existed starts its first sync;
        `synced` is false until it finishes.
      tags: [mailboxes]
      responses:
        "200":
          description: Mailboxes and counts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SupplierMailboxList" }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/integrations/{id}/mailboxes/sync:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: syncSupplierMailboxes
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Refresh the supplier's mailboxes now
      description: "Scope: mailboxes.manage. Queues a full sync. Mailboxes the supplier cancelled are paused in Outflare."
      tags: [mailboxes]
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ $ref: "#/components/schemas/Queued" }, { $ref: "#/components/schemas/MissionHeld" }] }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/integrations/{id}/mailboxes/import:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: importSupplierMailboxes
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Import mailboxes from a supplier
      description: |
        Scope: mailboxes.manage. Reads each mailbox's password from the
        supplier and adds it like a CSV import, with SMTP, IMAP and DNS
        checks in the background. Google mailboxes first get an app
        password from the supplier (Google refuses plain passwords), at the
        pace the supplier allows: Cheap Inboxes makes 20 an hour, so a large
        Google import takes hours; follow `import_state` in the list.
        Mailboxes still being set up wait and import when they turn active.
        Listed mailboxes are imported again even if they were before, which
        refreshes their passwords.
      tags: [mailboxes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                all: { type: boolean, description: Every mailbox not imported yet. }
                external_ids: { type: array, maxItems: 5000, items: { type: string } }
                emails: { type: array, maxItems: 5000, items: { type: string } }
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ $ref: "#/components/schemas/SupplierImportResult" }, { $ref: "#/components/schemas/MissionHeld" }] }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/emailbison-compat/calls:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listEmailBisonCompatCalls
      x-outflare-access: { scope: mailboxes.read }
      summary: List calls made to the EmailBison-compatible API
      description: |
        Scope: mailboxes.read. Outflare answers a subset of the EmailBison API
        at `/emailbison/{ws}/api/...` so a mailbox supplier that pushes to a
        self-hosted EmailBison (Cheap Inboxes does) can push into Outflare.
        Authenticate with an Outflare API token as the EmailBison API key; a
        write needs mailboxes.manage. Supported: `GET users`, `GET
        sender-emails` (page, per_page, search), `GET sender-emails/{id or
        email}`, `POST sender-emails/imap-smtp`, `POST sender-emails/bulk`,
        `PATCH sender-emails/{id}`, `DELETE sender-emails/{id}` (pauses the
        mailbox), `PATCH warmup/sender-emails/enable|disable`. Every call is
        logged here, newest first, with field names but never values, so
        any endpoint a client needs that Outflare does not answer yet shows
        up as `supported: false`. The latest 1,000 per workspace are kept.
      tags: [mailboxes]
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 200 } }
      responses:
        "200":
          description: Calls, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EmailBisonCompatCall" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/integrations/{id}/mailbox-sync:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getSupplierSync
      x-outflare-access: { scope: mailboxes.read }
      summary: How a supplier's mailboxes are synced
      description: "Scope: mailboxes.read."
      tags: [mailboxes]
      responses:
        "200":
          description: Settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SupplierSync" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateSupplierSync
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change how a supplier's mailboxes are synced
      description: "Scope: mailboxes.manage. Turning on auto_import queues every active mailbox not imported yet, and imports new ones as they appear."
      tags: [mailboxes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                auto_import: { type: boolean }
                app_passwords: { type: boolean, description: Ask the supplier for Google app passwords before importing Google mailboxes. On by default. }
                daily_limit: { type: [integer, "null"], minimum: 1, maximum: 500, description: "Daily cap for imported mailboxes. Null uses the supplier's cap." }
      responses:
        "200":
          description: Settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SupplierSync" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/integrations/{id}/webhook:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: supplierWebhook
      security: []
      summary: Where a supplier posts its events
      description: |
        Not called directly. Registered at the supplier when the engine's
        PUBLIC_URL is https. The body's HMAC-SHA256 signature, keyed with the
        secret the supplier issued, is checked; repeated deliveries are
        ignored.
      tags: [mailboxes]
      responses:
        "200": { description: Accepted. }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/stats:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: getWorkspaceStats
      x-outflare-access: { scope: workspace.read }
      summary: Workspace overview
      description: "Scope: workspace.read. Mailbox health, sending and reply counts, in one call. Windows are rolling (last 24 hours, last 7 days)."
      tags: [access]
      responses:
        "200":
          description: Counts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkspaceStats" }
        default: { $ref: "#/components/responses/Error" }
  /v1/connectors:
    get:
      operationId: listConnectorCatalog
      security: []
      summary: Every connector this server ships, available or planned
      description: |
        Public, no key needed. Each entry says what the connector is, how it
        signs in (`api_key`, `oauth2` or `none`), the fields to paste, and its
        capabilities. `configured` is false for OAuth connectors whose app is
        not set up on this server.
      tags: [connectors]
      responses:
        "200":
          description: The catalog, available first, then by category and name.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Connector" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/connectors:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listConnectors
      x-outflare-access: { scope: integrations.read }
      summary: The catalog, with how many accounts the workspace connected to each
      description: "Scope: integrations.read."
      tags: [connectors]
      parameters:
        - { name: category, in: query, schema: { $ref: "#/components/schemas/ConnectorCategory" } }
      responses:
        "200":
          description: The catalog.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Connector" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: listAnalyticsSources
      x-outflare-access: { scope: analytics.read }
      summary: List connected analytics tools
      description: |
        Scope: analytics.read. Every connection that reads website or
        product analytics or a search console (Google Analytics, Search
        Console, Bing Webmaster Tools, PostHog, Plausible and the rest),
        with the reports each one answers and its default site. Connect one
        with POST .../integrations (API key) or the OAuth authorize link,
        or hand a person a link with POST .../connect-requests. Nothing
        needs to be connected for the rest of Outflare to work.
      tags: [analytics]
      responses:
        "200":
          description: The connections.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/AnalyticsSource" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/summary:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: analyticsSummary
      x-outflare-access: { scope: analytics.read }
      summary: Traffic totals from every analytics tool
      description: |
        Scope: analytics.read. The traffic report's totals from every
        analytics connection over one range, in one call. A connection
        that fails (no site picked, revoked key) carries an error instead
        of failing the answer. Dates are inclusive; the default is the
        last 28 days.
      tags: [analytics]
      parameters:
        - { $ref: "#/components/parameters/AnalyticsFrom" }
        - { $ref: "#/components/parameters/AnalyticsTo" }
        - { $ref: "#/components/parameters/AnalyticsFresh" }
      responses:
        "200":
          description: One entry per connection.
          content:
            application/json:
              schema:
                type: object
                required: [from, to, data]
                properties:
                  from: { type: string, format: date }
                  to: { type: string, format: date }
                  data: { type: array, items: { $ref: "#/components/schemas/AnalyticsSummaryItem" } }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/history:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: getAnalyticsHistory
      x-outflare-access: { scope: analytics.read }
      summary: Query stored analytics history
      description: |
        Scope: analytics.read. Reads what the sync stored, without calling the tools.
        `group` is `day` (one row per day per tool; the default for traffic),
        `tool` (totals per tool), `key` (each page, source, query or event added up
        across the tools picked; the default for the other reports) or `tool_key`.
        Counts are summed, click-through rate and position are weighted by impressions.
      tags: [analytics]
      parameters:
        - { name: report, in: query, schema: { $ref: "#/components/schemas/AnalyticsReportName" } }
        - { name: group, in: query, schema: { type: string, enum: [day, tool, key, tool_key] } }
        - { name: integration, in: query, schema: { type: string, format: uuid }, description: Only this connection. }
        - { name: host, in: query, schema: { type: string }, example: example.com, description: Only this website. }
        - { name: from, in: query, schema: { type: string, format: date }, description: First day. Default 28 days before to. }
        - { name: to, in: query, schema: { type: string, format: date }, description: Last day. Default yesterday (UTC). }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 5000, default: 100 } }
      responses:
        "200":
          description: Stored rows.
          content:
            application/json:
              schema:
                type: object
                required: [report, from, to, group, data, notes]
                properties:
                  report: { $ref: "#/components/schemas/AnalyticsReportName" }
                  from: { type: string, format: date }
                  to: { type: string, format: date }
                  group: { type: string }
                  data: { type: array, items: { $ref: "#/components/schemas/AnalyticsHistoryRow" } }
                  notes: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/overview:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: getAnalyticsOverview
      x-outflare-access: { scope: analytics.read }
      summary: Every website across every tool
      description: "Scope: analytics.read. Stored traffic totals per website (by domain), with each tool side by side, search clicks added up across search consoles, and every connection's sync status."
      tags: [analytics]
      parameters:
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
      responses:
        "200":
          description: The overview.
          content:
            application/json:
              schema:
                type: object
                required: [from, to, data, syncs, notes]
                properties:
                  from: { type: string, format: date }
                  to: { type: string, format: date }
                  data: { type: array, items: { $ref: "#/components/schemas/AnalyticsOverviewSite" } }
                  syncs: { type: array, items: { $ref: "#/components/schemas/AnalyticsSync" } }
                  notes: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/changes:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: getAnalyticsChanges
      x-outflare-access: { scope: analytics.read }
      summary: What changed on each website
      description: >-
        Scope: analytics.read. Reads stored data only, so it is free. For each website (by domain): each tool's metrics over its own
        latest `days` stored days against the same number of days before (so a tool that runs behind is not read as a drop),
        search clicks added up across search consoles, the pages, sources and search queries that rose or fell most, unusual days
        against the 28 days before, the latest SEO snapshot against the one before (with keyword moves), plain-sentence headlines,
        and gaps: what is missing and which connector or action fixes it. Units for every metric are given once, in units.
      tags: [analytics]
      parameters:
        - { name: days, in: query, description: "Period length, 1 to 90. Default 7.", schema: { type: integer, minimum: 1, maximum: 90 } }
        - { name: to, in: query, description: "Last day to consider. Default yesterday, UTC.", schema: { type: string, format: date } }
        - { name: host, in: query, description: "One website, like example.com.", schema: { type: string } }
        - { name: limit, in: query, description: "Movers, unusual days and keyword moves per list, 1 to 25. Default 5.", schema: { type: integer, minimum: 1, maximum: 25 } }
      responses:
        "200":
          description: Changes per website.
          content:
            application/json:
              schema:
                type: object
                required: [days, to, websites, units, notes]
                properties:
                  days: { type: integer }
                  to: { type: string, format: date }
                  websites: { type: array, items: { $ref: "#/components/schemas/AnalyticsWebsiteChanges" } }
                  units: { type: object, additionalProperties: { type: string } }
                  notes: { type: array, items: { type: string } }
                  gaps: { type: array, description: "Only when no website is stored yet.", items: { $ref: "#/components/schemas/AnalyticsGap" } }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/catalog:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: getAnalyticsCatalog
      x-outflare-access: { scope: analytics.read }
      summary: What each website can be asked
      description: >-
        Scope: analytics.read. For each website: the analytics tools, which metrics each has stored per report, the days stored and
        last sync, the SEO tools with snapshots and budget left, and gaps with the connector or action that fixes each. ask maps
        common questions to the tool or endpoint that answers them.
      tags: [analytics]
      responses:
        "200":
          description: The catalog.
          content:
            application/json:
              schema:
                type: object
                required: [data, units, reports, ask]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/AnalyticsCatalogSite" } }
                  units: { type: object, additionalProperties: { type: string } }
                  reports: { type: object, additionalProperties: { type: string } }
                  ask: { type: object, additionalProperties: { type: string } }
                  gaps: { type: array, items: { $ref: "#/components/schemas/AnalyticsGap" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/compare:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: compareAnalyticsTools
      x-outflare-access: { scope: analytics.read }
      summary: Compare tools for one website, day by day
      description: "Scope: analytics.read. Each tool's stored daily traffic for one domain."
      tags: [analytics]
      parameters:
        - { name: host, in: query, required: true, schema: { type: string }, example: example.com }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
      responses:
        "200":
          description: One series per tool.
          content:
            application/json:
              schema:
                type: object
                required: [host, from, to, data, notes]
                properties:
                  host: { type: string }
                  from: { type: string, format: date }
                  to: { type: string, format: date }
                  data:
                    type: array
                    items:
                      type: object
                      required: [integration_id, name, connector, series]
                      properties:
                        integration_id: { type: string, format: uuid }
                        name: { type: string }
                        connector: { type: string }
                        series: { type: array, items: { $ref: "#/components/schemas/AnalyticsPoint" } }
                  notes: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/syncs:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: listAnalyticsSyncs
      x-outflare-access: { scope: analytics.read }
      summary: What the sync has stored for each connection
      description: "Scope: analytics.read."
      tags: [analytics]
      responses:
        "200":
          description: One entry per analytics connection.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/AnalyticsSync" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/sync:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    post:
      operationId: syncAllAnalytics
      x-outflare-access: { scope: analytics.read }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Sync every analytics connection soon
      description: "Scope: analytics.read. Queues a sync of each connected tool. Syncs also run by themselves every six hours (daily for Clarity)."
      tags: [analytics]
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ type: object, required: [queued], properties: { queued: { type: array, items: { type: string, format: uuid } } } }, { $ref: "#/components/schemas/MissionHeld" }] }
        "503": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/{id}/sync:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: syncAnalytics
      x-outflare-access: { scope: analytics.read }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Sync one analytics connection now
      description: |
        Scope: analytics.read. The first sync stores 90 days of traffic and 7 days of
        pages, sources, search queries and events; later ones re-read the last three days.
        Answers once it is done, with the rows stored and the calls made.
      tags: [analytics]
      responses:
        "200":
          description: Done.
          content:
            application/json:
              schema:
                type: object
                required: [integration_id, rows, calls]
                properties:
                  integration_id: { type: string, format: uuid }
                  rows: { type: integer }
                  calls: { type: integer }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/{id}/sites:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: The integration id. }
    get:
      operationId: listAnalyticsSites
      x-outflare-access: { scope: analytics.read }
      summary: List the sites a connection can read
      description: "Scope: analytics.read. GA4 properties, Search Console properties, PostHog projects, Fathom or Umami sites, and so on. Pass an id as `site` on reports, or make it the default."
      tags: [analytics]
      parameters:
        - { $ref: "#/components/parameters/AnalyticsFresh" }
      responses:
        "200":
          description: The sites, and the current default.
          content:
            application/json:
              schema:
                type: object
                required: [data, site]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/AnalyticsSite" } }
                  site: { type: string, description: The default site; empty when none is set. }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/{id}/site:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      operationId: setAnalyticsSite
      x-outflare-access: { scope: integrations.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Set a connection's default site
      description: "Scope: integrations.manage. The site must be one the connection can read. An empty site clears the default."
      tags: [analytics]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [site]
              properties:
                site: { type: string, example: "123456789" }
                domain: { type: string, example: example.com, description: "The website's domain, which lines this connection up with other tools in the overview and compare. Set it when the tool does not show one (Google Analytics properties, Vercel projects). An empty string clears it. Stored history moves to the new domain." }
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                required: [site]
                properties:
                  site: { type: string }
                  domain: { type: string }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/analytics/{id}/reports/{report}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: report, in: path, required: true, schema: { $ref: "#/components/schemas/AnalyticsReportName" } }
    get:
      operationId: getAnalyticsReport
      x-outflare-access: { scope: analytics.read }
      summary: Read one analytics report
      description: |
        Scope: analytics.read. The same report from any provider:
        `traffic` (totals plus one point per day), `pages`, `sources`
        (referrers), `search_queries` (Search Console and Bing) and
        `events` (events and conversions). A metric a provider does not
        measure is left out, never reported as zero. Read `notes` for
        caveats such as data delay. Answers are kept for 15 minutes (an
        hour for Mixpanel and Amplitude, six hours for Clarity) to spare
        the provider's rate limits; `fresh=true` reads again.
      tags: [analytics]
      parameters:
        - { name: site, in: query, schema: { type: string }, description: "A site id from .../sites. Default: the connection's default site, or its only one." }
        - { $ref: "#/components/parameters/AnalyticsFrom" }
        - { $ref: "#/components/parameters/AnalyticsTo" }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 20 }, description: Rows in a breakdown report. }
        - { $ref: "#/components/parameters/AnalyticsFresh" }
      responses:
        "200":
          description: The report.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalyticsReport" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/seo:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: listSEO
      x-outflare-access: { scope: analytics.read }
      summary: List SEO connections
      description: "Scope: analytics.read. DataForSEO and Ahrefs connections with their reports, the domains they follow, the country, the weekly snapshot setting and this month's budget and spend. Empty means none is connected; nothing else needs one."
      tags: [seo]
      responses:
        "200":
          description: SEO connections.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/SEOSource" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/seo/history:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: getSEOHistory
      x-outflare-access: { scope: analytics.read }
      summary: Read stored SEO snapshots
      description: |
        Scope: analytics.read. Reads what the weekly snapshots stored, without
        calling or paying the provider. `overview` gives one row per
        connection, domain and snapshot day. `keywords` (and the other
        lists) gives each connection's latest snapshot, with `previous`
        holding the same keyword's numbers from the snapshot before, unless
        `from` or `to` name a range.
      tags: [seo]
      parameters:
        - { name: report, in: query, schema: { $ref: "#/components/schemas/SEOReportName" } }
        - { name: host, in: query, schema: { type: string }, example: example.com }
        - { name: integration, in: query, schema: { type: string, format: uuid } }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 5000, default: 200 } }
      responses:
        "200":
          description: Stored rows, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [report, data]
                properties:
                  report: { $ref: "#/components/schemas/SEOReportName" }
                  data: { type: array, items: { $ref: "#/components/schemas/SEOHistoryRow" } }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/seo/{id}/settings:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      operationId: setSEOSettings
      x-outflare-access: { scope: integrations.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Set an SEO connection's domains, country, budget and snapshots
      description: "Scope: integrations.manage. Send only what changes. `domains: null` follows the domains the analytics tools line up (at most five); `monthly_budget: null` is the default ($10 for DataForSEO, 10,000 API units for Ahrefs); 0 stops every call."
      tags: [seo]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                domains: { type: [array, "null"], items: { type: string }, maxItems: 25, example: [example.com] }
                country: { type: string, example: US, description: Two-letter code for keywords and traffic. }
                monthly_budget: { type: [number, "null"], minimum: 0, description: "Per calendar month (UTC): US dollars for DataForSEO, API units for Ahrefs." }
                sync: { type: string, enum: [weekly, "off"] }
      responses:
        "200":
          description: The connection with its new settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SEOSource" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/seo/{id}/reports/{report}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: report, in: path, required: true, schema: { $ref: "#/components/schemas/SEOReportName" } }
    get:
      operationId: getSEOReport
      x-outflare-access: { scope: analytics.read }
      summary: Read one SEO report live (costs money)
      description: |
        Scope: analytics.read. Asks the provider now, which costs money:
        `cost` says what this answer cost and `budget` what is left this
        month. A call that would go over the connection's monthly budget
        answers 402 `budget_reached` without calling. Answers are kept for
        a day, so asking again is free; `fresh=true` pays again. Every paid
        call is recorded in Outcomes spend. Prefer GET .../seo/history for
        anything the weekly snapshot already has.
      tags: [seo]
      parameters:
        - { name: target, in: query, schema: { type: string }, example: example.com, description: "A domain. Default: the connection's only domain." }
        - { name: country, in: query, schema: { type: string }, example: US }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 50 } }
        - { name: fresh, in: query, schema: { type: boolean } }
      responses:
        "200":
          description: The report.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SEOAnswer" }
        "400": { $ref: "#/components/responses/Error" }
        "402": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/seo/{id}/sync:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: syncSEO
      x-outflare-access: { scope: integrations.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Take an SEO snapshot now
      description: "Scope: integrations.manage. Stores the overview and top 50 keywords of every domain the connection follows, within its budget (this also runs weekly). Costs about $0.04 a domain on DataForSEO or 1,200 API units on Ahrefs."
      tags: [seo]
      responses:
        "200":
          description: What was stored and what it cost.
          content:
            application/json:
              schema:
                type: object
                required: [domains, rows, cost, unit]
                properties:
                  domains: { type: array, items: { type: string } }
                  rows: { type: integer }
                  cost: { type: number }
                  unit: { type: string, enum: [USD, API unit] }
        "400": { $ref: "#/components/responses/Error" }
        "402": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/integrations:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listIntegrations
      x-outflare-access: { scope: integrations.read }
      summary: List connected accounts
      description: "Scope: integrations.read. Credentials are never returned."
      tags: [connectors]
      parameters:
        - { name: connector, in: query, schema: { type: string }, description: Only this connector id. }
        - { name: category, in: query, schema: { $ref: "#/components/schemas/ConnectorCategory" } }
      responses:
        "200":
          description: Integrations, oldest first.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Integration" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createIntegration
      x-outflare-access: { scope: integrations.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Connect an account with an API key
      description: |
        Scope: integrations.manage. The credentials are tested against the
        service before anything is saved, and refused with
        `connector_auth` if wrong. OAuth connectors are connected through
        `GET /integrations/oauth/{connector}/authorize` instead.
      tags: [connectors]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [connector, credentials]
              properties:
                connector: { type: string, example: zerobounce }
                name: { type: string, description: "Defaults to the connector's name, numbered if taken." }
                credentials:
                  type: object
                  additionalProperties: { type: string }
                  description: The connector's fields by name, e.g. {"api_key":"..."}.
      responses:
        "201":
          description: Connected.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Integration" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/integrations/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getIntegration
      x-outflare-access: { scope: integrations.read }
      summary: Get a connected account
      description: "Scope: integrations.read."
      tags: [connectors]
      responses:
        "200":
          description: The integration.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Integration" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateIntegration
      x-outflare-access: { scope: integrations.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Rename, replace the API key, or change settings
      description: "Scope: integrations.manage. New credentials are tested before they replace the old ones."
      tags: [connectors]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                credentials: { type: object, additionalProperties: { type: string } }
                config: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Integration" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteIntegration
      x-outflare-access: { scope: integrations.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Disconnect an account
      description: "Scope: integrations.manage. Deletes the stored credentials. Features using it fall back (verification goes back to the built-in checks)."
      tags: [connectors]
      responses:
        "204": { description: Disconnected. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/integrations/{id}/test:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: testIntegration
      x-outflare-access: { scope: integrations.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Sign in again with the stored credentials
      description: "Scope: integrations.manage. Reads the account (and credits, when the service reports them); changes nothing at the service and spends no credits."
      tags: [connectors]
      responses:
        "200":
          description: The integration with its status and last_error updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Integration" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/integrations/oauth/{connector}/authorize:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: connector, in: path, required: true, schema: { type: string } }
    get:
      operationId: authorizeIntegration
      x-outflare-access: { scope: integrations.manage }
      summary: Start connecting an OAuth connector
      description: |
        Scope: integrations.manage. Redirects to the service's sign-in page;
        afterwards the browser lands on the workspace's Connectors page.
        With `format=json` it returns the link instead, for an agent to hand
        to a person.
      tags: [connectors]
      parameters:
        - { name: name, in: query, schema: { type: string } }
        - { name: format, in: query, schema: { type: string, enum: [json] } }
      responses:
        "200":
          description: The sign-in link (format=json).
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url: { type: string, format: uri }
        "302": { description: Redirect to the sign-in page. }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/integrations/oauth/clients/{file}:
    get:
      operationId: integrationOAuthClientMetadata
      tags: [connectors]
      summary: OAuth client metadata document
      description: "For providers that read the OAuth client from a URL (PostHog). The document's own URL is the client id, so nobody registers an app. `{file}` is `<connector id>.json`."
      security: []
      parameters:
        - { name: file, in: path, required: true, schema: { type: string }, example: posthog_cloud.json }
      responses:
        "200": { description: The client metadata document., content: { application/json: { schema: { type: object, additionalProperties: true } } } }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/integrations/oauth/callback:
    get:
      operationId: integrationOAuthCallback
      security: []
      summary: Where connector sign-in pages return to
      description: Not called directly. Redirects to the Connectors page with `integration` (the new integration id) or `connect_error`.
      tags: [connectors]
      responses:
        "302": { description: Redirect to the web app. }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/verification-settings:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: getVerificationSettings
      x-outflare-access: { scope: leads.read }
      summary: How the workspace verifies addresses
      description: "Scope: leads.read."
      tags: [verification]
      responses:
        "200":
          description: The settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerificationSettings" }
        default: { $ref: "#/components/responses/Error" }
    put:
      operationId: updateVerificationSettings
      x-outflare-access: { scope: leads.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Choose the verification service and what to block
      description: |
        Scope: leads.manage. Every field is optional. `integration_id` empty
        or `builtin` switches to the free built-in checks. Undeliverable
        addresses are always blocked.
      tags: [verification]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                integration_id: { type: [string, "null"] }
                verify_on_import: { type: boolean }
                verify_before_send: { type: boolean }
                block_risky: { type: boolean }
                block_catch_all: { type: boolean }
                block_unknown: { type: boolean }
                max_age_days: { type: integer, minimum: 1, maximum: 3650 }
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerificationSettings" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/verifications:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listVerifications
      x-outflare-access: { scope: leads.read }
      summary: Verification results, newest first
      description: "Scope: leads.read."
      tags: [verification]
      parameters:
        - { name: status, in: query, schema: { $ref: "#/components/schemas/VerificationStatus" } }
        - { name: email, in: query, schema: { type: string }, description: "One or more addresses, comma-separated or repeated." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
      responses:
        "200":
          description: Results.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Verification" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: startVerification
      x-outflare-access: { scope: leads.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Verify addresses, a lead list, or every unverified lead
      description: |
        Scope: leads.manage. Runs in the background; results appear under
        GET /verifications. Addresses with a fresh result are skipped unless
        `force` is true. Paid services spend credits.
      tags: [verification]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                emails: { type: array, items: { type: string, format: email } }
                list_id: { type: string, format: uuid }
                unverified: { type: boolean, description: Every lead without a fresh result. }
                force: { type: boolean }
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ type: object, required: [queued, jobs], properties: { queued: { type: integer }, jobs: { type: integer } } }, { $ref: "#/components/schemas/MissionHeld" }] }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/verifications/summary:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: getVerificationSummary
      x-outflare-access: { scope: leads.read }
      summary: Lead counts by verification status
      description: "Scope: leads.read."
      tags: [verification]
      responses:
        "200":
          description: Counts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerificationSummary" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/outcomes/report:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: outcomesReport
      x-outflare-access: { scope: outcomes.read }
      summary: Cost per reply, meeting and deal
      description: |
        Scope: outcomes.read. Sends, replies, meetings, deals and spend over a
        period, for the workspace or per project or sequence, with cost per
        reply, per meeting, per held meeting and per deal. Spend tied to a
        sequence, project, mailbox or domain is charged to it directly; the
        rest is shared out by each row's share of emails sent. Usage recorded
        only as units (credits) is priced with the workspace's spend rates and
        reported as estimated; usage with no rate is listed under `unpriced`.
        Nothing is converted between currencies.
      tags: [outcomes]
      parameters:
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
        - { name: group_by, in: query, schema: { type: string, enum: [none, project, sequence], default: none } }
        - { name: project_id, in: query, schema: { type: string, format: uuid } }
        - { name: sequence_id, in: query, schema: { type: string, format: uuid } }
        - { name: mission_id, in: query, schema: { type: string, format: uuid }, description: "Only meetings, deals and spend recorded for this mission." }
        - { name: currency, in: query, schema: { type: string, default: USD, example: AUD } }
      responses:
        "200":
          description: The report.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OutcomesReport" }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/outcomes/export:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: exportOutcomes
      x-outflare-access: { scope: outcomes.read }
      summary: Export meetings, deals, spend or the report
      description: |
        Scope: outcomes.read. Rows for a warehouse or a spreadsheet, as CSV
        (default) or newline-delimited JSON. Up to 100,000 rows per call;
        narrow from and to for more. The report dataset takes the report's
        query parameters and defaults to group_by=sequence.
      tags: [outcomes]
      parameters:
        - { name: dataset, in: query, required: true, schema: { type: string, enum: [meetings, deals, spend, report] } }
        - { name: format, in: query, schema: { type: string, enum: [csv, ndjson], default: csv } }
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
        - { name: group_by, in: query, schema: { type: string, enum: [none, project, sequence] } }
        - { name: currency, in: query, schema: { type: string } }
      responses:
        "200":
          description: The file.
          content:
            text/csv: { schema: { type: string } }
            application/x-ndjson: { schema: { type: string } }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/outcomes/syncs:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: listOutcomeSyncs
      x-outflare-access: { scope: outcomes.read }
      summary: Calendar and CRM sync status
      description: "Scope: outcomes.read. Every connected calendar, booking tool and CRM, with when each stream (meetings, deals, push) last ran, what it changed and its last error. Syncs run every 15 minutes."
      tags: [outcomes]
      responses:
        "200":
          description: Sync status.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/OutcomeSync" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/outcomes/syncs/{id}/run:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: The integration id. }
    post:
      operationId: runOutcomeSync
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Sync a calendar or CRM now
      description: "Scope: outcomes.manage. Queues a sync instead of waiting for the next one."
      tags: [outcomes]
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ type: object, required: [job_id, status], properties: { job_id: { type: integer }, status: { type: string, enum: [queued] } } }, { $ref: "#/components/schemas/MissionHeld" }] }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/meetings:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: listMeetings
      x-outflare-access: { scope: outcomes.read }
      summary: List meetings with leads
      description: "Scope: outcomes.read. Newest first. Page with before=next_before."
      tags: [outcomes]
      parameters:
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
        - { name: project_id, in: query, schema: { type: string, format: uuid } }
        - { name: sequence_id, in: query, schema: { type: string, format: uuid } }
        - { name: mission_id, in: query, schema: { type: string, format: uuid } }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/MeetingStatus" } }
        - { name: attributed, in: query, schema: { type: boolean }, description: Only meetings tied to outreach. }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
        - { name: before, in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Meetings.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_before]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Meeting" } }
                  next_before: { type: [string, "null"], format: date-time }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createMeeting
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Log a meeting
      description: |
        Scope: outcomes.manage. For meetings no connected calendar sees. Give
        lead_id or attendee_email; the meeting is tied to the sequence that
        last emailed that lead, unless sequence_id or project_id is given
        (which pins it). Status defaults to held for a meeting more than an
        hour in the past, otherwise booked.
      tags: [outcomes]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewMeeting" }
      responses:
        "201":
          description: The meeting.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Meeting" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/meetings/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getMeeting
      x-outflare-access: { scope: outcomes.read }
      summary: Get a meeting
      description: "Scope: outcomes.read."
      tags: [outcomes]
      responses:
        "200":
          description: The meeting.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Meeting" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateMeeting
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Mark a meeting held, no-show or qualified, or fix its attribution
      description: |
        Scope: outcomes.manage. Setting sequence_id, project_id or
        unattributed pins the attribution so syncs never move it. A status a
        person sets (held, no_show) is kept by syncs, unless the source
        cancels the meeting.
      tags: [outcomes]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MeetingPatch" }
      responses:
        "200":
          description: The meeting.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Meeting" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteMeeting
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Delete a meeting
      description: "Scope: outcomes.manage. A synced meeting comes back on the next sync; mark it canceled or unattributed instead."
      tags: [outcomes]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/deals:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: listDeals
      x-outflare-access: { scope: outcomes.read }
      summary: List deals
      description: "Scope: outcomes.read. Deals from the CRM, plus any logged by hand. Newest first."
      tags: [outcomes]
      parameters:
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
        - { name: project_id, in: query, schema: { type: string, format: uuid } }
        - { name: sequence_id, in: query, schema: { type: string, format: uuid } }
        - { name: mission_id, in: query, schema: { type: string, format: uuid } }
        - { name: state, in: query, schema: { type: string, enum: [open, won, lost] } }
        - { name: attributed, in: query, schema: { type: boolean } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
        - { name: before, in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Deals.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_before]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Deal" } }
                  next_before: { type: [string, "null"], format: date-time }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createDeal
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Log a deal
      description: "Scope: outcomes.manage. For workspaces without a connected CRM. Give meeting_id, lead_id or contact_email to tie it to outreach."
      tags: [outcomes]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewDeal" }
      responses:
        "201":
          description: The deal.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Deal" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/deals/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getDeal
      x-outflare-access: { scope: outcomes.read }
      summary: Get a deal
      description: "Scope: outcomes.read."
      tags: [outcomes]
      responses:
        "200":
          description: The deal.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Deal" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateDeal
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change a deal or its attribution
      description: "Scope: outcomes.manage. A deal synced from a CRM only takes attribution changes here; change its name, stage or amount in the CRM."
      tags: [outcomes]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DealPatch" }
      responses:
        "200":
          description: The deal.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Deal" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteDeal
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Delete a deal
      description: "Scope: outcomes.manage. Removes the copy here only; the CRM is not touched."
      tags: [outcomes]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/spend:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: listSpend
      x-outflare-access: { scope: outcomes.read }
      summary: List the spend ledger
      description: "Scope: outcomes.read. One-off charges, newest first. The ledger records cost; it never bills. Providers are paid directly."
      tags: [outcomes]
      parameters:
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
        - { name: project_id, in: query, schema: { type: string, format: uuid } }
        - { name: sequence_id, in: query, schema: { type: string, format: uuid } }
        - { name: mission_id, in: query, schema: { type: string, format: uuid } }
        - { name: category, in: query, schema: { $ref: "#/components/schemas/SpendCategory" } }
        - { name: provider, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
        - { name: before, in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Entries.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_before]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/SpendEntry" } }
                  next_before: { type: [string, "null"], format: date-time }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: recordSpend
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Record spend
      description: |
        Scope: outcomes.manage. Give amount_cents when the money is known, or
        units with a unit_name (e.g. 1200 credit) to have reports price it
        with the workspace's rates. With a ref already in the ledger nothing
        is added and the existing entry comes back with 200, so retries never
        double count. project_id is filled from the sequence, mailbox or
        domain when left out.
      tags: [outcomes]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewSpend" }
      responses:
        "201":
          description: Recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpendEntry" }
        "200":
          description: The ref was recorded before; the existing entry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpendEntry" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/spend/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getSpend
      x-outflare-access: { scope: outcomes.read }
      summary: Get a spend entry
      description: "Scope: outcomes.read."
      tags: [outcomes]
      responses:
        "200":
          description: The entry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpendEntry" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteSpend
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Delete a spend entry
      description: "Scope: outcomes.manage. For entries recorded by mistake. The deletion is in the audit log with the amount."
      tags: [outcomes]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/spend-recurring:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: listRecurringSpend
      x-outflare-access: { scope: outcomes.read }
      summary: List recurring costs
      description: "Scope: outcomes.read. Mailbox seats, subscriptions, people. Reports spread each over the days it was active."
      tags: [outcomes]
      responses:
        "200":
          description: Recurring costs.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/RecurringSpend" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createRecurringSpend
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Add a recurring cost
      description: "Scope: outcomes.manage."
      tags: [outcomes]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecurringSpendInput" }
      responses:
        "201":
          description: Added.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecurringSpend" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/spend-recurring/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      operationId: updateRecurringSpend
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change a recurring cost
      description: "Scope: outcomes.manage. Replaces every field. To stop a cost, set ends_on; past reports keep it."
      tags: [outcomes]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecurringSpendInput" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecurringSpend" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteRecurringSpend
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Delete a recurring cost
      description: "Scope: outcomes.manage. Removes it from every report, past ones included. Set ends_on instead to keep history."
      tags: [outcomes]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/spend-rates:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: listSpendRates
      x-outflare-access: { scope: outcomes.read }
      summary: List unit prices
      description: "Scope: outcomes.read. Every rate, plus a zero-priced row for each provider and unit used in the ledger without one, so it is clear what still needs a price."
      tags: [outcomes]
      responses:
        "200":
          description: Rates.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/SpendRate" } }
        default: { $ref: "#/components/responses/Error" }
    put:
      operationId: setSpendRate
      x-outflare-access: { scope: outcomes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Set what a unit costs
      description: "Scope: outcomes.manage. Providers match without case. unit_price_micros is millionths of the currency unit: a $0.004 credit is 4000."
      tags: [outcomes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider, unit_name, unit_price_micros]
              properties:
                provider: { type: string, example: zerobounce }
                unit_name: { type: string, example: credit }
                unit_price_micros: { type: integer, minimum: 0, example: 4000 }
                currency: { type: string, description: Default USD. }
      responses:
        "200":
          description: The rate.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpendRate" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteSpendRate
      x-outflare-access: { scope: outcomes.manage }
      summary: Remove a unit price
      description: "Scope: outcomes.manage."
      tags: [outcomes]
      parameters:
        - { name: provider, in: query, required: true, schema: { type: string } }
        - { name: unit_name, in: query, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listMissions
      x-outflare-access: { scope: missions.read }
      summary: List missions
      description: "Scope: missions.read. Waiting for approval first, then running, paused, drafts and ended."
      tags: [missions]
      parameters:
        - { name: status, in: query, description: "Comma-separated statuses.", schema: { type: string, example: "running,paused" } }
        - { name: project_id, in: query, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Missions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Mission" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createMission
      x-outflare-access: { scope: missions.run }
      summary: Create a mission
      description: |
        Scope: missions.run. Starts as a draft: writes made in it are dry
        runs until a person approves it. A mission created with an API token
        is run by that token; a person can assign any token with token_id.
        A token bound to a project creates missions only in that project.
      tags: [missions]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/MissionInput" }
                - { required: [name] }
      responses:
        "201": { description: Created., content: { application/json: { schema: { $ref: "#/components/schemas/Mission" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/plan:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: planMission
      x-outflare-access: { scope: missions.run }
      summary: Plan a mission from a goal
      description: |
        Scope: missions.run. Type a goal in plain words ("book 10 meetings
        with fintech CFOs in Australia by 30 November") and Claude drafts
        the mission: a measurable goal, steps, instructions for the agent,
        limits sized to your mailboxes, and the connectors it needs. The
        draft is saved as a mission in status draft, with a connect request
        for each missing connector and any questions for you. Nothing runs
        until a person approves it.

        Uses the workspace's Claude connection (connect it under Connectors
        with your own API key). Without one the call fails with 409
        `connector_needed`. Claude is sent the goal, the project brief and a
        summary of the workspace, never lead lists or email addresses.
      tags: [missions]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MissionPlanInput" }
      responses:
        "201":
          description: The draft mission and what the planner proposed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MissionPlanResult" }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getMission
      x-outflare-access: { scope: missions.read }
      summary: Check in on a mission
      description: |
        Scope: missions.read. What an agent reads before it acts: the
        mission, the project brief (and whether it changed since approval),
        limits, progress, open connect requests and the latest notes.
      tags: [missions]
      responses:
        "200":
          description: The mission and its context.
          content:
            application/json:
              schema:
                type: object
                properties:
                  mission: { $ref: "#/components/schemas/Mission" }
                  brief: { oneOf: [{ $ref: "#/components/schemas/MissionBrief" }, { type: "null" }] }
                  updates: { type: array, items: { $ref: "#/components/schemas/MissionUpdate" } }
                  open_connect_requests: { type: array, items: { $ref: "#/components/schemas/MissionUpdate" } }
                  how_to_act:
                    type: object
                    properties:
                      header: { type: string }
                      reason_header: { type: string }
                      writes: { type: string }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateMission
      x-outflare-access: { scope: missions.run }
      summary: Change a mission
      description: |
        Scope: missions.run. Once a mission is approved, changing its mode,
        limits, token or project also needs missions.approve, and an agent
        cannot change its own. An ended mission cannot change.
      tags: [missions]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MissionInput" }
      responses:
        "200": { description: Updated., content: { application/json: { schema: { $ref: "#/components/schemas/Mission" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/submit:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: submitMission
      x-outflare-access: { scope: missions.run }
      summary: Submit the plan for approval
      description: "Scope: missions.run. From draft. Writes stay dry runs until approved."
      tags: [missions]
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { plan: { type: string, description: Markdown. } } }
      responses:
        "200": { description: Submitted., content: { application/json: { schema: { $ref: "#/components/schemas/Mission" } } } }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/approve:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: approveMission
      x-outflare-access: { scope: missions.approve }
      summary: Approve and start a mission
      description: |
        Scope: missions.approve. A person, or an approver token that is not
        the mission's own. Records the project brief version it was
        approved against.

        `runner: builtin` hands the mission to Outflare's own agent, which
        runs it in turns with the workspace's Claude key. It gets a token of
        its own (operator scopes, never more than yours, never
        missions.approve) and goes through the same gate as any agent, so
        the mode still decides whether its writes run, wait for you or are
        dry runs. A turn runs at once, whenever a person writes to the
        mission or decides on a held change, and when the agent asks to look
        again. Answers 409 `connector_needed` until Claude is connected.
      tags: [missions]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                mode: { $ref: "#/components/schemas/MissionMode" }
                note: { type: string }
                runner:
                  type: string
                  enum: [builtin, external]
                  description: Who carries the mission out. Left out, it stays as it is (external for a new mission).
      responses:
        "200": { description: Running., content: { application/json: { schema: { $ref: "#/components/schemas/Mission" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/pause:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: pauseMission
      x-outflare-access: { scope: missions.run }
      summary: Pause a mission
      description: "Scope: missions.run, so an agent can pause itself. Writes are refused and its campaigns stop sending until it resumes."
      tags: [missions]
      requestBody:
        content: { application/json: { schema: { type: object, properties: { reason: { type: string } } } } }
      responses:
        "200": { description: Paused., content: { application/json: { schema: { $ref: "#/components/schemas/Mission" } } } }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/resume:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: resumeMission
      x-outflare-access: { scope: missions.approve }
      summary: Resume a paused mission
      description: "Scope: missions.approve, and not the mission's own agent."
      tags: [missions]
      responses:
        "200": { description: Running., content: { application/json: { schema: { $ref: "#/components/schemas/Mission" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/stop:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: stopMission
      x-outflare-access: { scope: missions.run }
      summary: Stop a mission now
      description: "Scope: missions.run. Ends it: held writes are canceled, its campaigns are paused, and further calls in it are refused."
      tags: [missions]
      requestBody:
        content: { application/json: { schema: { type: object, properties: { reason: { type: string } } } } }
      responses:
        "200": { description: Stopped., content: { application/json: { schema: { $ref: "#/components/schemas/Mission" } } } }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/complete:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: completeMission
      x-outflare-access: { scope: missions.run }
      summary: Finish a mission with a report
      description: "Scope: missions.run. The agent's final report, in Markdown. Its campaigns are paused."
      tags: [missions]
      requestBody:
        content: { application/json: { schema: { type: object, properties: { report: { type: string } } } } }
      responses:
        "200": { description: Completed., content: { application/json: { schema: { $ref: "#/components/schemas/Mission" } } } }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/calls:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: listMissionCalls
      x-outflare-access: { scope: missions.read }
      summary: Every call made in the mission, newest first
      description: "Scope: missions.read. What a write changed is in the audit log under the same request_id."
      tags: [missions]
      parameters:
        - { name: writes, in: query, schema: { type: boolean }, description: Only writes. }
        - { name: before, in: query, schema: { type: integer }, description: A call id, for the next page. }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        "200":
          description: Calls.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/MissionCall" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/actions:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: listMissionActions
      x-outflare-access: { scope: missions.read }
      summary: Writes the mission held or dry-ran
      description: "Scope: missions.read."
      tags: [missions]
      parameters:
        - { name: status, in: query, description: "Comma-separated.", schema: { type: string, example: pending } }
      responses:
        "200":
          description: Actions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/MissionAction" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/actions/{action}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: action, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getMissionAction
      x-outflare-access: { scope: missions.read }
      summary: One held write, with its decision and result
      description: "Scope: missions.read. Agents poll this after a 202."
      tags: [missions]
      responses:
        "200": { description: The action., content: { application/json: { schema: { $ref: "#/components/schemas/MissionAction" } } } }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/actions/{action}/approve:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: action, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: approveMissionAction
      x-outflare-access: { scope: missions.approve }
      summary: Approve and run a held write
      description: |
        Scope: missions.approve. Runs the stored request as the agent that
        made it, through every normal check, and keeps the response as the
        result. Works on pending writes, and on dry runs once the mission
        is running. Never by the agent that made it.
      tags: [missions]
      requestBody:
        content: { application/json: { schema: { type: object, properties: { note: { type: string } } } } }
      responses:
        "200": { description: The action, executed or failed., content: { application/json: { schema: { $ref: "#/components/schemas/MissionAction" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/actions/{action}/reject:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: action, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: rejectMissionAction
      x-outflare-access: { scope: missions.approve }
      summary: Reject a held write
      description: "Scope: missions.approve. A note is passed to the agent as an instruction."
      tags: [missions]
      requestBody:
        content: { application/json: { schema: { type: object, properties: { note: { type: string } } } } }
      responses:
        "200": { description: Rejected., content: { application/json: { schema: { $ref: "#/components/schemas/MissionAction" } } } }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/updates:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: listMissionUpdates
      x-outflare-access: { scope: missions.read }
      summary: Notes between the agent and people
      description: "Scope: missions.read."
      tags: [missions]
      parameters:
        - { name: open_requests, in: query, schema: { type: boolean }, description: Only connect requests not yet resolved. }
        - { name: before, in: query, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        "200":
          description: Updates.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/MissionUpdate" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: postMissionUpdate
      x-outflare-access: { scope: missions.run }
      summary: Post a note, question, instruction or connect request
      description: |
        Scope: missions.run. kind connect_request asks a person to connect
        a tool the agent needs (they paste the key or sign in with OAuth);
        give connector as the connector id.
      tags: [missions]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                kind: { type: string, enum: [note, progress, question, instruction, connect_request], default: note }
                body: { type: string }
                connector: { type: string, example: zerobounce }
      responses:
        "201": { description: Posted., content: { application/json: { schema: { $ref: "#/components/schemas/MissionUpdate" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/missions/{id}/updates/{update}/resolve:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: update, in: path, required: true, schema: { type: integer } }
    post:
      operationId: resolveMissionConnectRequest
      x-outflare-access: { scope: missions.run }
      summary: Close a connect request
      description: "Scope: missions.run. The tool was connected, or a person decided not to."
      tags: [missions]
      requestBody:
        content: { application/json: { schema: { type: object, properties: { note: { type: string } } } } }
      responses:
        "204": { description: Resolved. }
        "409": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mission-actions:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listPendingMissionActions
      x-outflare-access: { scope: missions.read }
      summary: Writes waiting for a person, across missions
      description: "Scope: missions.read. The approval inbox."
      tags: [missions]
      responses:
        "200":
          description: Pending actions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/MissionAction" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/connect-requests:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: listConnectRequests
      x-outflare-access: { scope: integrations.read }
      summary: List connect requests
      description: "Scope: integrations.read. The 100 newest, with status pending, connected, cancelled or expired."
      tags: [connectors]
      responses:
        "200":
          description: Connect requests, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/ConnectRequest" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createConnectRequest
      x-outflare-access: { scope: integrations.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Ask a person to connect a service
      description: |
        Scope: integrations.manage. The agent-first way to connect anything:
        returns a one-time link to give the person. They open it, without
        logging in, and paste the key or approve the OAuth sign-in. The
        secret never passes through the agent. Poll
        GET /connect-requests/{id} until status is connected.
      tags: [connectors]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [connector]
              properties:
                connector: { type: string, example: zerobounce }
                name: { type: string, description: Name for the integration it creates. }
                reason: { type: string, maxLength: 500, description: Why it is needed; shown to the person on the link's page. }
                use_for_verification: { type: boolean, description: "Make it the workspace's verification service. Default true for verification connectors." }
                expires_in_minutes: { type: integer, minimum: 10, maximum: 10080, description: Default 1440 (24 hours). }
      responses:
        "201":
          description: Created. `url` is only returned here.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectRequest" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/connect-requests/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getConnectRequest
      x-outflare-access: { scope: integrations.read }
      summary: Read a connect request
      description: "Scope: integrations.read. Once status is connected, integration_id is the new integration."
      tags: [connectors]
      responses:
        "200":
          description: The request.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectRequest" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: cancelConnectRequest
      x-outflare-access: { scope: integrations.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Cancel a pending connect request
      description: "Scope: integrations.manage. The link stops working."
      tags: [connectors]
      responses:
        "204": { description: Cancelled. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/connect/lookup:
    post:
      operationId: connectLookup
      summary: What a connect link is for
      description: Used by the link's page. No login; the token from the link is the authority.
      tags: [connectors]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [token], properties: { token: { type: string } } }
      responses:
        "200":
          description: The connector and who asked.
          content:
            application/json:
              schema:
                type: object
                required: [workspace_name, connector, name, reason, requested_by, expires_at]
                properties:
                  workspace_name: { type: string }
                  connector: { $ref: "#/components/schemas/Connector" }
                  name: { type: string }
                  reason: { type: string }
                  requested_by: { type: string, enum: [agent, person] }
                  expires_at: { type: string, format: date-time }
        "404": { $ref: "#/components/responses/Error" }
        "410": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/connect/complete:
    post:
      operationId: connectComplete
      summary: Finish a connect link with an API key
      description: Tests the credentials and saves the integration. The link then stops working.
      tags: [connectors]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, credentials]
              properties:
                token: { type: string }
                credentials: { type: object, additionalProperties: { type: string } }
      responses:
        "201":
          description: Connected.
          content:
            application/json:
              schema:
                type: object
                required: [connector_name, name, workspace_name]
                properties:
                  connector_name: { type: string }
                  name: { type: string }
                  workspace_name: { type: string }
                  account: { $ref: "#/components/schemas/ConnectorAccount" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "410": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/connect/oauth:
    post:
      operationId: connectOAuth
      summary: Start the OAuth sign-in for a connect link
      tags: [connectors]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [token], properties: { token: { type: string } } }
      responses:
        "200":
          description: The provider's sign-in URL. Afterwards the browser lands on /connect.
          content:
            application/json:
              schema: { type: object, required: [url], properties: { url: { type: string } } }
        "400": { $ref: "#/components/responses/Error" }
        "410": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/integrations/{id}/import-preview:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: importPreview
      x-outflare-access: { scope: integrations.read }
      summary: See what an import from another sequencer would bring over
      description: "Scope: integrations.read. Reads the connected Instantly or Smartlead account live: campaigns with their steps, and sending accounts matched to this workspace's mailboxes by address. Changes nothing."
      tags: [imports]
      responses:
        "200":
          description: The outside account's campaigns and sending accounts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportPreview" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/integrations/{id}/imports:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: startImport
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Import campaigns, leads and mailboxes from another sequencer
      description: |
        Scope: sequences.manage, plus leads.manage when importing leads. Runs
        in the background; read it with GET /imports/{id}. Campaigns become
        draft sequences, so nothing sends until someone starts them. Leads
        keep the step they reached and the mailbox that wrote to them;
        bounced and unsubscribed leads go on the suppression list. Running it
        again updates the same sequences instead of duplicating them.
      tags: [imports]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                campaign_ids: { type: array, items: { type: string }, description: Only these campaigns. Default every campaign. }
                leads: { type: boolean, description: Default true. }
                mailboxes: { type: boolean, description: "Match sending accounts. Default true." }
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ $ref: "#/components/schemas/ImportRun" }, { $ref: "#/components/schemas/MissionHeld" }] }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/imports:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: listImports
      x-outflare-access: { scope: sequences.read }
      summary: List imports
      description: "Scope: sequences.read. The 50 newest."
      tags: [imports]
      responses:
        "200":
          description: Imports, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/ImportRun" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/imports/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getImport
      x-outflare-access: { scope: sequences.read }
      summary: Read an import and what it did
      description: "Scope: sequences.read."
      tags: [imports]
      responses:
        "200":
          description: The import.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportRun" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailbox-health:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: mailboxHealth
      x-outflare-access: { scope: mailboxes.read }
      summary: How every sending mailbox is doing
      description: |
        Scope: mailboxes.read. Graded from every signal: sign-in and inbox sync
        errors (explained, e.g. suspended account or revoked consent), send
        failures, bounce rate, warm-up inbox placement, the domain's SPF, DKIM,
        DMARC and blocklists, and new mailboxes not warming up. Each signal
        says what to do. Worst first. Computed on read.
      tags: [mailboxes]
      parameters:
        - { name: level, in: query, schema: { type: string, enum: [ok, warning, critical, paused] } }
        - { name: mailbox_id, in: query, schema: { type: string }, description: One or more ids, comma separated. }
      responses:
        "200":
          description: Health by mailbox.
          content:
            application/json:
              schema:
                type: object
                required: [summary, data]
                properties:
                  summary: { $ref: "#/components/schemas/MailboxHealthSummary" }
                  data: { type: array, items: { $ref: "#/components/schemas/MailboxHealth" } }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailbox-health/settings:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: getHealthSettings
      x-outflare-access: { scope: mailboxes.read }
      summary: Automatic pausing settings
      description: "Scope: mailboxes.read."
      tags: [mailboxes]
      responses:
        "200":
          description: The settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthSettings" }
        default: { $ref: "#/components/responses/Error" }
    put:
      operationId: putHealthSettings
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change automatic pausing
      description: "Scope: mailboxes.manage. Every field is optional."
      tags: [mailboxes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                auto_pause: { type: boolean }
                pause_bounce_rate: { type: number }
                min_sends: { type: integer }
      responses:
        "200":
          description: The settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthSettings" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/reports:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
    get:
      operationId: report
      x-outflare-access: { scope: sequences.read }
      summary: Outreach and deliverability report
      description: |
        Scope: sequences.read. Sends, leads contacted, new leads and follow-ups,
        failures, policy blocks, bounces, replies (and replied leads), auto-replies,
        opt-outs, unsubscribes and warm-up placement, with reply, bounce, opt-out
        and placement rates. Totals for the period, and rows by day, week,
        sequence, step, mailbox or domain. Default period is the last 30 days.
      tags: [reports]
      parameters:
        - { name: from, in: query, schema: { type: string }, description: "A date (2026-10-01) or RFC 3339 time." }
        - { name: to, in: query, schema: { type: string }, description: "A date (inclusive) or RFC 3339 time. Default now." }
        - { name: group_by, in: query, schema: { type: string, enum: [day, week, sequence, step, mailbox, domain] } }
        - { name: timezone, in: query, schema: { type: string }, description: "IANA zone for dates and day buckets. Default UTC." }
        - { name: sequence_id, in: query, schema: { type: string, format: uuid } }
        - { name: mailbox_id, in: query, schema: { type: string, format: uuid } }
        - { name: domain, in: query, schema: { type: string } }
      responses:
        "200":
          description: The report.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Report" }
        "400": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/projects:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listProjects
      x-outflare-access: { scope: projects.read, project_tokens: true }
      summary: List projects
      description: |
        Scope: projects.read. A project is one go-to-market motion: an offer
        sold to one ideal customer in one market. It holds the brief people
        and agents work from and owns domains, mailboxes, lead lists and
        sequences. A project token sees only its own project.
      tags: [projects]
      parameters:
        - name: status
          in: query
          description: Default lists every project but archived ones.
          schema: { type: string, enum: [active, paused, archived, all] }
      responses:
        "200":
          description: Projects by name, archived last.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Project" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createProject
      x-outflare-access: { scope: projects.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Create a project
      description: "Scope: projects.manage, not available to project tokens. Names are unique in the workspace, ignoring case."
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/ProjectInput"
                - required: [name]
      responses:
        "201":
          description: The project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/projects/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getProject
      x-outflare-access: { scope: projects.read, project_tokens: true }
      summary: Get a project
      description: "Scope: projects.read."
      tags: [projects]
      responses:
        "200":
          description: The project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateProject
      x-outflare-access: { scope: projects.manage, project_tokens: true }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Edit a project's brief, status or daily send limit
      description: |
        Scope: projects.manage. A change to offer, audience, market, brief or
        icp bumps `brief_version`. Send the `brief_version` you read to make
        sure you are not overwriting someone else's edit: if the brief has
        changed since, the update fails with 409 `brief_changed`. `status:
        paused` holds every send from the project's sequences; `archived`
        also hides it from the default list.
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectInput" }
      responses:
        "200":
          description: The project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteProject
      x-outflare-access: { scope: projects.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Delete a project
      description: |
        Scope: projects.manage, not available to project tokens. Its domains,
        mailboxes, lists and sequences stay and become shared by the
        workspace. Fails with 409 `project_has_tokens` while the project has
        live API tokens; revoke them first, or archive the project instead.
      tags: [projects]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/projects/{id}/brief:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getProjectBrief
      x-outflare-access: { scope: projects.read, project_tokens: true }
      summary: Read a project's brief
      description: |
        Scope: projects.read. What an agent should read before writing copy,
        building a list or planning a campaign for the project: the
        structured fields plus the whole brief as one Markdown document.
        Keep `version` and compare it later to know the brief moved.
      tags: [projects]
      responses:
        "200":
          description: The brief.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProjectBrief" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/projects/{id}/add:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: addToProject
      x-outflare-access: { scope: projects.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Move domains, mailboxes, lists or sequences into a project
      description: |
        Scope: projects.manage, plus mailboxes.manage for domains and
        mailboxes, leads.manage for lists and sequences.manage for
        sequences. Not available to project tokens. Records in another
        project move to this one. A domain brings its mailboxes unless
        `with_mailboxes` is false. A project's mailboxes only send for that
        project's sequences, so moving one can take it off a sequence (it
        finishes follow-ups first).
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectMembers" }
      responses:
        "200":
          description: How many records moved. Unknown ids and names are ignored.
          content:
            application/json:
              schema:
                type: object
                required: [moved]
                properties:
                  moved: { $ref: "#/components/schemas/ProjectMoved" }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/projects/{id}/remove:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: removeFromProject
      x-outflare-access: { scope: projects.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Make a project's domains, mailboxes, lists or sequences shared again
      description: "Scope: projects.manage, plus read access to each kind of record moved (as addToProject). Only records in this project change."
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectMembers" }
      responses:
        "200":
          description: How many records moved.
          content:
            application/json:
              schema:
                type: object
                required: [moved]
                properties:
                  moved: { $ref: "#/components/schemas/ProjectMoved" }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/teams:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listTeams
      x-outflare-access: { scope: members.read }
      summary: List teams
      description: "Scope: members.read. Teams own sequences and senders. A workspace with no teams shares every mailbox."
      tags: [allocation]
      responses:
        "200":
          description: Teams by name.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Team" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createTeam
      x-outflare-access: { scope: members.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Create a team
      description: "Scope: members.manage. Names are unique in the workspace, ignoring case."
      tags: [allocation]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TeamInput" }
      responses:
        "201":
          description: The team.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Team" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/teams/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      operationId: updateTeam
      x-outflare-access: { scope: members.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Rename a team, change its mode or replace its members
      description: "Scope: members.manage. member_ids replaces the whole list when sent."
      tags: [allocation]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TeamInput" }
      responses:
        "200":
          description: The team.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Team" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteTeam
      x-outflare-access: { scope: members.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Delete a team
      description: "Scope: members.manage. Its sequences and senders stay, without a team."
      tags: [allocation]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/senders:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listSenders
      x-outflare-access: { scope: mailboxes.read }
      summary: List senders
      description: |
        Scope: mailboxes.read. A sender is who mailboxes send as: a person, a
        persona, or a shared identity. Each mailbox has at most one sender,
        always confirmed by a person or agent, never guessed.
      tags: [allocation]
      responses:
        "200":
          description: Senders by name.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Sender" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createSender
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Create a sender
      description: "Scope: mailboxes.manage."
      tags: [allocation]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SenderInput" }
      responses:
        "201":
          description: The sender.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sender" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/senders/{id}:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      operationId: updateSender
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change a sender, or retire it
      description: "Scope: mailboxes.manage. A retired sender's mailboxes finish their follow-ups and take no new leads."
      tags: [allocation]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SenderInput" }
      responses:
        "200":
          description: The sender.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sender" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteSender
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Delete a sender
      description: "Scope: mailboxes.manage. Its mailboxes become unconfirmed."
      tags: [allocation]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/senders/matches:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listSenderMatches
      x-outflare-access: { scope: mailboxes.read }
      summary: Suggest senders for unconfirmed mailboxes
      description: |
        Scope: mailboxes.read. Matches a mailbox to a sender only when its
        display name is exactly the sender's name. Confirm each with
        POST /mailboxes/sender.
      tags: [allocation]
      responses:
        "200":
          description: Matches, plus how many mailboxes have no confirmed sender.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        mailbox_id: { type: string, format: uuid }
                        email: { type: string }
                        display_name: { type: string }
                        sender_id: { type: string, format: uuid }
                        sender_name: { type: string }
                  unconfirmed: { type: integer }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes/sender:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: setMailboxSender
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Confirm who mailboxes send as, in bulk
      description: "Scope: mailboxes.manage. A null sender_id clears it."
      tags: [allocation]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mailbox_ids, sender_id]
              properties:
                mailbox_ids: { type: array, maxItems: 5000, items: { type: string, format: uuid } }
                sender_id: { type: [string, "null"], format: uuid }
      responses:
        "200":
          description: How many mailboxes changed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/allocation/settings:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: getAllocationSettings
      x-outflare-access: { scope: sequences.read }
      summary: Get the workspace's allocation mode
      description: "Scope: sequences.read. Teams and sequences can override the mode."
      tags: [allocation]
      responses:
        "200":
          description: The mode.
          content:
            application/json:
              schema:
                type: object
                properties:
                  mode: { $ref: "#/components/schemas/AllocationMode" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateAllocationSettings
      x-outflare-access: { scope: members.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Set the workspace's allocation mode
      description: "Scope: members.manage."
      tags: [allocation]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode: { $ref: "#/components/schemas/AllocationMode" }
      responses:
        "200":
          description: The mode.
          content:
            application/json:
              schema:
                type: object
                properties:
                  mode: { $ref: "#/components/schemas/AllocationMode" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/allocation/plan:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: previewAllocation
      x-outflare-access: { scope: sequences.read }
      summary: Dry run, showing what the allocator would change now
      description: "Scope: sequences.read. Changes nothing."
      tags: [allocation]
      responses:
        "200":
          description: Planned changes and each active sequence's capacity.
          content:
            application/json:
              schema:
                type: object
                properties:
                  changes: { type: array, items: { $ref: "#/components/schemas/PlannedChange" } }
                  capacity: { type: array, items: { $ref: "#/components/schemas/AllocationCapacity" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/allocation/run:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: runAllocation
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Plan and apply now
      description: |
        Scope: sequences.manage. The allocator also runs every minute and
        after any relevant change. In suggest mode, and for changes that need
        another team's or the owner's approval, it records suggestions instead.
      tags: [allocation]
      responses:
        "200":
          description: What was applied and what was suggested.
          content:
            application/json:
              schema:
                type: object
                properties:
                  applied: { type: array, items: { $ref: "#/components/schemas/PlannedChange" } }
                  suggested: { type: array, items: { $ref: "#/components/schemas/PlannedChange" } }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/allocation/suggestions:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listAllocationSuggestions
      x-outflare-access: { scope: sequences.read }
      summary: List suggestions waiting on a person
      description: "Scope: sequences.read."
      tags: [allocation]
      parameters:
        - { name: sequence_id, in: query, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Suggestions, approvals first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/AllocationSuggestion" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/allocation/suggestions/{id}/apply:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: applyAllocationSuggestion
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Apply or approve a suggestion
      description: |
        Scope: sequences.manage. A team_approval needs a member of that team,
        or a workspace owner or admin. An owner_approval needs a person, not
        an API token. Fails with 409 if the mailbox changed since.
      tags: [allocation]
      responses:
        "200":
          description: The change made.
          content:
            application/json:
              schema:
                type: object
                properties:
                  applied: { $ref: "#/components/schemas/PlannedChange" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/allocation/suggestions/{id}/decline:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: declineAllocationSuggestion
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Decline a suggestion, so it is not proposed again
      description: "Scope: sequences.manage."
      tags: [allocation]
      responses:
        "204": { description: Declined. }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/allocation/changes:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listAllocationChanges
      x-outflare-access: { scope: sequences.read }
      summary: Mailbox assignment history, newest first
      description: "Scope: sequences.read."
      tags: [allocation]
      parameters:
        - { name: sequence_id, in: query, schema: { type: string, format: uuid } }
        - { name: mailbox_id, in: query, schema: { type: string, format: uuid } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
      responses:
        "200":
          description: Changes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/AllocationChange" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/allocation/changes/{id}/undo:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: undoAllocationChange
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Undo a change
      description: |
        Scope: sequences.manage. Only the latest change to a mailbox can be
        undone. Undoing an automatic choice also stops the allocator from
        making it again.
      tags: [allocation]
      responses:
        "200":
          description: The reversing change.
          content:
            application/json:
              schema:
                type: object
                properties:
                  applied: { $ref: "#/components/schemas/PlannedChange" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/allocation/attention:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: getAllocationAttention
      x-outflare-access: { scope: sequences.read }
      summary: What needs a person
      description: "Scope: sequences.read. Approvals, suggestions to review, shortages, follow-ups whose mailbox is gone and unconfirmed senders."
      tags: [allocation]
      responses:
        "200":
          description: Items needing attention.
          content:
            application/json:
              schema:
                type: object
                properties:
                  approvals: { type: array, items: { $ref: "#/components/schemas/AllocationSuggestion" } }
                  suggestions_to_review: { type: integer }
                  shortages:
                    type: array
                    items:
                      type: object
                      properties:
                        sequence_id: { type: string, format: uuid }
                        sequence_name: { type: string }
                        needed_daily: { type: integer }
                        active_daily: { type: integer }
                        blocking: { type: array, items: { $ref: "#/components/schemas/AllocationBlocking" } }
                        note: { type: string }
                  orphaned_follow_ups:
                    type: array
                    items:
                      type: object
                      properties:
                        sequence_id: { type: string, format: uuid }
                        sequence_name: { type: string }
                        leads: { type: integer }
                  unconfirmed_senders: { type: integer, description: Only counted once the workspace has teams. }
                  platforms:
                    type: array
                    description: Platform accounts that are out of date, have a first plan to review, or have mailboxes in conflict.
                    items: { $ref: "#/components/schemas/PlatformAccount" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/platforms:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listPlatforms
      x-outflare-access: { scope: sequences.read }
      summary: List connected sequencer accounts
      description: "Scope: sequences.read. Every connected account of an outside sequencer (EmailBison, Instantly, Smartlead) that mailbox allocation reads. Outflare reads each one every 5 minutes; its live campaigns become sequences with `links`, and its mailboxes become mailboxes with `has_login: false` unless Outflare already has them."
      tags: [allocation]
      responses:
        "200":
          description: The accounts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/PlatformAccount" } }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/platforms/{id}/sync:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, description: The connection (integration) id., schema: { type: string, format: uuid } }
    post:
      operationId: syncPlatform
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Read a sequencer account now
      description: "Scope: sequences.manage. Reads mailboxes and campaigns now instead of waiting for the next 5-minute sync. Mailboxes added to or removed from a campaign in the platform are accepted as manual changes; attaches Outflare asked for are confirmed once the platform shows them."
      tags: [allocation]
      responses:
        "200":
          description: What the sync found and changed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  mailboxes: { type: integer }
                  campaigns: { type: integer, description: Live campaigns linked to sequences. }
                  accepted: { type: integer, description: Changes made in the platform, accepted as manual ones. }
                  confirmed: { type: integer, description: Attaches the platform now shows. }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/platforms/{id}/review:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, description: The connection (integration) id., schema: { type: string, format: uuid } }
    post:
      operationId: reviewPlatformPlan
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Let allocation change a newly connected account
      description: "Scope: sequences.manage. Until this is called, every change for the account's campaigns is only suggested, so you can look at the first plan before anything moves. Runs a plan straight away."
      tags: [allocation]
      responses:
        "200":
          description: The plan that ran.
          content:
            application/json:
              schema:
                type: object
                properties:
                  applied: { type: array, items: { $ref: "#/components/schemas/AllocationChange" } }
                  suggested: { type: array, items: { $ref: "#/components/schemas/AllocationChange" } }
        "404": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes/assign:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: assignMailboxes
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Assign mailboxes to a sequence by hand
      description: |
        Scope: sequences.manage. A mailbox with leads mid-sequence elsewhere
        drains first: it finishes those follow-ups, then joins. Locks are
        honoured; a mailbox locked elsewhere is refused.
      tags: [allocation]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mailbox_ids, sequence_id]
              properties:
                mailbox_ids: { type: array, maxItems: 5000, items: { type: string, format: uuid } }
                sequence_id: { type: string, format: uuid }
      responses:
        "200":
          description: One result per mailbox.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/MailboxAllocationResult" } }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes/release:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: releaseMailboxes
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Take mailboxes off their sequence
      description: "Scope: sequences.manage. Mailboxes with leads in flight drain first."
      tags: [allocation]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mailbox_ids]
              properties:
                mailbox_ids: { type: array, maxItems: 5000, items: { type: string, format: uuid } }
      responses:
        "200":
          description: One result per mailbox.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/MailboxAllocationResult" } }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/mailboxes/lock:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: lockMailboxes
      x-outflare-access: { scope: mailboxes.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Lock mailboxes, or clear their locks
      description: |
        Scope: mailboxes.manage. sequence: keep on this sequence (assigns it
        now). team: only this team's sequences. manual_only: the allocator
        never touches it. A null kind clears the lock. until is optional.
      tags: [allocation]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mailbox_ids, kind]
              properties:
                mailbox_ids: { type: array, maxItems: 5000, items: { type: string, format: uuid } }
                kind: { type: [string, "null"], enum: [sequence, team, manual_only, null] }
                sequence_id: { type: string, format: uuid }
                team_id: { type: string, format: uuid }
                until: { type: [string, "null"], format: date-time }
                note: { type: string }
      responses:
        "200":
          description: Mailboxes changed, and any assignments a sequence lock made.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer }
                  assignments: { type: array, items: { $ref: "#/components/schemas/MailboxAllocationResult" } }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sequences/{id}/allocation:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getSequenceAllocation
      x-outflare-access: { scope: sequences.read }
      summary: A sequence's mailboxes, capacity, suggestions and history
      description: "Scope: sequences.read."
      tags: [allocation]
      responses:
        "200":
          description: The sequence's allocation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sequence_id: { type: string, format: uuid }
                  mode: { $ref: "#/components/schemas/AllocationMode" }
                  needed_daily: { type: [integer, "null"] }
                  active_daily: { type: integer }
                  blocking: { type: array, items: { $ref: "#/components/schemas/AllocationBlocking" } }
                  note: { type: string }
                  planned_at: { type: [string, "null"], format: date-time }
                  mailboxes: { type: array, items: { $ref: "#/components/schemas/AssignedMailbox" } }
                  incoming: { type: array, items: { $ref: "#/components/schemas/AssignedMailbox" }, description: Mailboxes draining elsewhere that join next. }
                  suggestions: { type: array, items: { $ref: "#/components/schemas/AllocationSuggestion" } }
                  changes: { type: array, items: { $ref: "#/components/schemas/AllocationChange" } }
                  orphaned_follow_ups: { type: integer }
                  links: { type: array, items: { $ref: "#/components/schemas/CampaignLink" } }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/sequences/{id}/orphaned-follow-ups:
    parameters:
      - { $ref: "#/components/parameters/Workspace" }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: resolveOrphanedFollowUps
      x-outflare-access: { scope: sequences.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Decide what happens to follow-ups whose mailbox is gone
      description: |
        Scope: sequences.manage. cancel stops them. handoff sends the next
        step from another of the sequence's mailboxes as a fresh email, not a
        reply, since the new sender never wrote before.
      tags: [allocation]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [cancel, handoff] }
      responses:
        "200":
          description: How many leads changed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/infrastructure:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: getInfrastructure
      x-outflare-access: { scope: infrastructure.read }
      summary: Mailboxes by lifecycle stage, the ones not ready, and plans in progress
      description: |
        Scope: infrastructure.read. Counts of mailboxes in each lifecycle
        stage (warming, ready, resting, retired), every mailbox that is not
        ready with the reason and until when, the plans that are not finished,
        and the suppliers plans can buy from on this server.
      tags: [infrastructure]
      responses:
        "200":
          description: Overview.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InfrastructureOverview" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/infrastructure/preview:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: previewInfraPlan
      x-outflare-access: { scope: infrastructure.read }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Draft a plan without saving it
      description: |
        Scope: infrastructure.read. Takes the same fields as creating a plan
        and returns the domains, mailboxes, timeline, daily capacity and
        warnings it would have. Nothing is saved or bought.
      tags: [infrastructure]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InfraPlanInput" }
      responses:
        "200":
          description: The draft.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InfraPlanDraft" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/infrastructure/move:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    post:
      operationId: moveMailboxStage
      x-outflare-access: { scope: infrastructure.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Move mailboxes to warming, ready, resting or retired
      description: |
        Scope: infrastructure.manage. Works on any mailbox, including ones
        you imported yourself. Only ready mailboxes send cold email; warm-up
        carries on while warming and resting. Retiring pauses the mailbox and
        turns warm-up off for good; its unfinished follow-ups wait for you to
        cancel them or hand them to another mailbox. A mailbox that cannot
        make the move is returned with `skipped` saying why.
      tags: [infrastructure]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mailbox_ids, stage]
              properties:
                mailbox_ids: { type: array, items: { type: string, format: uuid }, minItems: 1, maxItems: 1000 }
                stage: { $ref: "#/components/schemas/LifecycleStage" }
                reason: { type: string, maxLength: 500, description: Shown on the mailbox and in the audit log. }
                until:
                  type: string
                  format: date-time
                  description: "Resting: when the rest ends (default 7 days). Warming: the earliest it can be ready (default 14 days)."
                auto_rest:
                  type: boolean
                  description: Rest on its own when warm-up inbox placement drops, and come back when it recovers.
                cancel_at_supplier:
                  type: boolean
                  description: Retiring a mailbox a plan bought also cancels it at the supplier, at the end of the period.
      responses:
        "200":
          description: What happened to each mailbox.
          content:
            application/json:
              schema:
                type: object
                required: [moved, results]
                properties:
                  moved: { type: integer }
                  results:
                    type: array
                    items:
                      type: object
                      required: [id, email, from, to]
                      properties:
                        id: { type: string, format: uuid }
                        email: { type: string }
                        from: { $ref: "#/components/schemas/LifecycleStage" }
                        to: { $ref: "#/components/schemas/LifecycleStage" }
                        skipped: { type: string }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/infra-plans:
    parameters: [{ $ref: "#/components/parameters/Workspace" }]
    get:
      operationId: listInfraPlans
      x-outflare-access: { scope: infrastructure.read }
      summary: List infrastructure plans
      description: "Scope: infrastructure.read. Newest first, without their items."
      tags: [infrastructure]
      responses:
        "200":
          description: Plans.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/InfraPlan" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createInfraPlan
      x-outflare-access: { scope: infrastructure.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Draft a plan for domains and mailboxes
      description: |
        Scope: infrastructure.manage. "10 domains and 30 mailboxes for this
        project by the 14th" is `domain_count: 10, mailboxes_per_domain: 3,
        ready_by: "2026-11-14", project_id`. Domain names come from `brand`
        unless you list them in `domains`; listing a domain the workspace
        already has adds mailboxes to it instead of buying it. Mailbox names
        come from `personas`. A plan with a supplier is priced in the
        background; read it again until `quote.status` is `ready`. A draft
        buys nothing until it is approved.
      tags: [infrastructure]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/InfraPlanInput"
                - required: [name, domain_count]
      responses:
        "201":
          description: The draft plan.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InfraPlan" }
        "400": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/infra-plans/{id}:
    parameters:
      - $ref: "#/components/parameters/Workspace"
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      operationId: getInfraPlan
      x-outflare-access: { scope: infrastructure.read }
      summary: Get a plan with every domain and mailbox and where each one is
      description: "Scope: infrastructure.read"
      tags: [infrastructure]
      responses:
        "200":
          description: The plan.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InfraPlan" }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateInfraPlan
      x-outflare-access: { scope: infrastructure.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Change a draft plan
      description: |
        Scope: infrastructure.manage. Drafts only. Domains already in the plan
        are kept unless you send `domains`; ones the supplier said are taken
        are replaced with new suggestions.
      tags: [infrastructure]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InfraPlanInput" }
      responses:
        "200":
          description: The plan.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InfraPlan" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      operationId: deleteInfraPlan
      x-outflare-access: { scope: infrastructure.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Delete a draft or cancelled plan
      description: "Scope: infrastructure.manage. Domains and mailboxes it built stay."
      tags: [infrastructure]
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/infra-plans/{id}/approve:
    parameters:
      - $ref: "#/components/parameters/Workspace"
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: approveInfraPlan
      x-outflare-access: { scope: infrastructure.approve }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Approve a plan, which starts buying
      description: |
        Scope: infrastructure.approve (owners and admins; give it to an agent
        token only if it may spend). Fails with 409 while the plan has a
        blocking warning. When the plan has a price, send `once_cents` and
        `monthly_cents` exactly as its `quote` shows them, so a price that
        changed is never approved by accident. Customers pay the supplier
        directly, on their own account.
      tags: [infrastructure]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                once_cents: { type: integer, format: int64 }
                monthly_cents: { type: integer, format: int64 }
      responses:
        "200":
          description: The plan, now building.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InfraPlan" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/infra-plans/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/Workspace"
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: cancelInfraPlan
      x-outflare-access: { scope: infrastructure.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Stop a plan
      description: |
        Scope: infrastructure.manage. Steps not done yet are cancelled. An
        order already placed with a supplier is not undone; cancel it in your
        supplier account if you need to.
      tags: [infrastructure]
      responses:
        "200":
          description: The plan.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InfraPlan" }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/infra-plans/{id}/bought:
    parameters:
      - $ref: "#/components/parameters/Workspace"
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: markInfraDomainsBought
      x-outflare-access: { scope: infrastructure.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Tell a plan you bought its domains
      description: |
        Scope: infrastructure.manage. For plans you buy yourself (`supplier:
        manual`). Moves the domains on to the DNS check. A domain also moves
        on by itself once its MX records point at the plan's provider.
      tags: [infrastructure]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                domains:
                  type: array
                  items: { type: string }
                  description: The domains bought; every domain waiting to be bought when left out.
      responses:
        "200":
          description: How many domains moved on.
          content:
            application/json:
              schema:
                type: object
                required: [updated]
                properties:
                  updated: { type: integer }
        "409": { $ref: "#/components/responses/Error" }
        "202": { $ref: "#/components/responses/MissionHeld" }
        default: { $ref: "#/components/responses/Error" }
  /v1/workspaces/{ws}/infra-plans/{id}/refresh:
    parameters:
      - $ref: "#/components/parameters/Workspace"
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: refreshInfraPlan
      x-outflare-access: { scope: infrastructure.manage }
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      summary: Get a new quote for a draft, or check a building plan now
      description: "Scope: infrastructure.manage. Runs in the background. Building plans are also checked every 5 minutes on their own."
      tags: [infrastructure]
      responses:
        "202":
          description: "Queued. Inside a mission, a MissionHeld answer when the call was held for approval or dry-run instead."
          content:
            application/json:
              schema: { oneOf: [{ $ref: "#/components/schemas/Queued" }, { $ref: "#/components/schemas/MissionHeld" }] }
        "404": { $ref: "#/components/responses/Error" }
        default: { $ref: "#/components/responses/Error" }
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: An API token (oft_...) created in the workspace.
    session:
      type: apiKey
      in: cookie
      name: outflare_session
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: |
        Makes the write safe to retry. A repeat with the same key from the same caller within 24 hours gets the first
        answer back (with `Idempotent-Replayed: true`) instead of running again. The same key for a different request
        is refused with 422 `idempotency_key_reused`; while the first is still running, 409 `idempotency_in_progress`.
        Use a new random key, such as a UUID, for each distinct write.
      schema: { type: string, minLength: 1, maxLength: 255 }
    Agency:
      name: ag
      in: path
      required: true
      schema: { type: string, format: uuid }
    Workspace:
      name: ws
      in: path
      required: true
      schema: { type: string, format: uuid }
    Domain:
      name: domain
      in: path
      required: true
      schema: { type: string, example: acme.com }
    From:
      name: from
      in: query
      description: Start of the period, inclusive. A date (2026-10-01) or a time. Default 30 days before to.
      schema: { type: string, example: "2026-10-01" }
    To:
      name: to
      in: query
      description: End of the period. A date includes that whole day. Default now.
      schema: { type: string, example: "2026-10-31" }
    AnalyticsFrom:
      name: from
      in: query
      description: First day, inclusive (YYYY-MM-DD). Default 27 days before to.
      schema: { type: string, format: date, example: "2026-09-27" }
    AnalyticsTo:
      name: to
      in: query
      description: Last day, inclusive (YYYY-MM-DD). Default today (UTC).
      schema: { type: string, format: date, example: "2026-10-24" }
    AnalyticsFresh:
      name: fresh
      in: query
      description: true skips the short cache and asks the provider again.
      schema: { type: boolean, default: false }
  responses:
    Error:
      description: Error.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    MissionHeld:
      description: |
        Only for a call made inside a mission (with the Outflare-Mission header): the write did not run. It was recorded
        as a dry run, or held until a person approves it. Read mission_action to follow it.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/MissionHeld" }
  schemas:
    EmailBisonCompatCall:
      type: object
      required: [method, path, query_keys, body_keys, status, supported, user_agent, created_at]
      properties:
        method: { type: string }
        path: { type: string, description: "Path after /emailbison/{ws}, e.g. /api/sender-emails." }
        query_keys: { type: array, items: { type: string } }
        body_keys: { type: array, items: { type: string }, description: "Field names sent, one level deep (sender_emails[].email). Never values." }
        status: { type: integer }
        supported: { type: boolean }
        user_agent: { type: string }
        created_at: { type: string, format: date-time }
    TelemetryOffReason:
      description: Why usage stats are off; null when on. env is OUTFLARE_TELEMETRY or DO_NOT_TRACK, setting is the owner's choice, build is a build that turns it off (the hosted Cloud), no_endpoint is a build with nowhere to send it.
      type: [string, "null"]
      enum: [env, setting, build, no_endpoint, null]
    TelemetryNotice:
      type: object
      required: [id, severity, title]
      properties:
        id: { type: string }
        severity: { type: string, enum: [info, warning, critical] }
        title: { type: string }
        url: { type: string }
    TelemetryStatus:
      type: object
      required: [enabled, disabled_by, endpoint, instance_id, version, last_sent_at, last_error, latest_version, update_available, notices, changed_at, can_change]
      properties:
        enabled: { type: boolean }
        disabled_by: { $ref: "#/components/schemas/TelemetryOffReason" }
        endpoint: { type: [string, "null"], description: Where reports go. }
        instance_id: { type: string, format: uuid, description: Random id naming this install; nothing else. }
        version: { type: string }
        last_sent_at: { type: [string, "null"], format: date-time }
        last_error: { type: [string, "null"] }
        latest_version: { type: [string, "null"] }
        update_available: { type: boolean }
        notices:
          type: array
          items: { $ref: "#/components/schemas/TelemetryNotice" }
        changed_at: { type: [string, "null"], format: date-time }
        can_change: { type: boolean, description: True for the person who set up the server. }
    TelemetryCounts:
      description: Each count is a range such as "6-10", never exact.
      type: object
      required: [users, workspaces, mailboxes, active_mailboxes, leads, active_sequences, sent_24h, replies_24h, projects, missions, api_tokens, active_users_7d]
      properties:
        users: { type: string }
        workspaces: { type: string }
        mailboxes: { type: string }
        active_mailboxes: { type: string }
        leads: { type: string }
        active_sequences: { type: string }
        sent_24h: { type: string }
        replies_24h: { type: string }
        projects: { type: string }
        missions: { type: string }
        api_tokens: { type: string }
        active_users_7d: { type: string }
    TelemetryReport:
      type: object
      required: [schema, instance_id, version, os, arch, go_version, container, postgres_major, install_age_days, counts, connectors]
      properties:
        schema: { type: integer }
        instance_id: { type: string, format: uuid }
        version: { type: string }
        os: { type: string }
        arch: { type: string }
        go_version: { type: string }
        container: { type: boolean }
        postgres_major: { type: integer }
        install_age_days: { type: string }
        counts: { $ref: "#/components/schemas/TelemetryCounts" }
        connectors:
          type: array
          description: Ids of connectors in use, such as hubspot. Never the accounts they sign in to.
          items: { type: string }
    MissionMode:
      type: string
      enum: [dry_run, approve_writes, autonomous]
      description: "dry_run: writes recorded, never run. approve_writes: each write waits for a person. autonomous: writes run within the limits."
    MissionStatus:
      type: string
      enum: [draft, pending_approval, running, paused, completed, stopped, failed]
    MissionPlanInput:
      type: object
      required: [goal]
      properties:
        goal: { type: string, maxLength: 2000, example: "Book 10 meetings with CFOs at Australian fintechs with 50 to 500 staff by 30 November" }
        project_id: { type: string, format: uuid, description: "Plan inside this project, using its brief." }
    MissionDraft:
      type: object
      description: What the planner proposed. The mission holds the same values; this keeps the parts a mission has no field for.
      required: [steps, connectors, questions, assumptions]
      properties:
        steps:
          type: array
          items: { type: object, required: [title, detail], properties: { title: { type: string }, detail: { type: string } } }
        connectors:
          type: array
          description: Connectors the plan needs that are not connected yet.
          items: { type: object, required: [id, why], properties: { id: { type: string }, name: { type: string }, why: { type: string } } }
        questions: { type: array, items: { type: string } }
        assumptions: { type: array, items: { type: string } }
    MissionPlanResult:
      type: object
      required: [mission, draft, usage]
      properties:
        mission: { $ref: "#/components/schemas/Mission" }
        draft: { $ref: "#/components/schemas/MissionDraft" }
        usage:
          type: object
          required: [model, input_tokens, output_tokens]
          properties:
            model: { type: string, example: claude-opus-5-5 }
            input_tokens: { type: integer }
            output_tokens: { type: integer }
    MissionInput:
      type: object
      properties:
        name: { type: string }
        project_id: { type: [string, "null"], format: uuid }
        goal: { type: string, description: The goal in words. }
        goal_metric: { type: [string, "null"], enum: [meetings, replies, sends, leads, null] }
        goal_target: { type: [integer, "null"], minimum: 1 }
        instructions: { type: string, description: Guidance on top of the project brief (Markdown). }
        plan: { type: string, description: The agent's plan (Markdown). }
        mode: { $ref: "#/components/schemas/MissionMode" }
        token_id: { type: [string, "null"], format: uuid, description: The only token that may act in the mission. }
        max_sends: { type: [integer, "null"], minimum: 1 }
        max_spend_cents: { type: [integer, "null"], minimum: 0, description: US cents, from the spend ledger. }
        ends_at: { type: [string, "null"], format: date-time, description: The mission ends itself at this time. }
    Mission:
      type: object
      required: [id, name, project_id, project_name, goal, instructions, plan, plan_updated_at, mode, status, status_reason, token_id, token_name, limits, progress, writes, pending_actions, calls, last_call_at, brief_version, approved_by, approved_by_name, approved_at, started_at, ended_at, report, created_by, created_by_name, started_by_agent, runner, turns, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        project_id: { type: [string, "null"], format: uuid }
        project_name: { type: [string, "null"] }
        goal:
          type: object
          required: [text, metric, target, current, reached]
          properties:
            text: { type: string }
            metric: { type: [string, "null"], enum: [meetings, replies, sends, leads, null] }
            target: { type: [integer, "null"] }
            current: { type: integer }
            reached: { type: boolean }
        instructions: { type: string }
        plan: { type: string }
        plan_updated_at: { type: [string, "null"], format: date-time }
        mode: { $ref: "#/components/schemas/MissionMode" }
        status: { $ref: "#/components/schemas/MissionStatus" }
        status_reason: { type: string }
        token_id: { type: [string, "null"], format: uuid }
        token_name: { type: [string, "null"] }
        limits:
          type: object
          required: [max_sends, max_spend_cents, ends_at]
          properties:
            max_sends: { type: [integer, "null"] }
            max_spend_cents: { type: [integer, "null"] }
            ends_at: { type: [string, "null"], format: date-time }
        progress:
          type: object
          required: [sends, replies, leads, meetings, spend_cents]
          properties:
            sends: { type: integer }
            replies: { type: integer }
            leads: { type: integer }
            meetings: { type: integer }
            spend_cents: { type: integer }
        writes:
          type: string
          enum: [run, wait_for_approval, dry_run, refused]
          description: What happens to a write made in the mission right now.
        pending_actions: { type: integer }
        calls: { type: integer }
        last_call_at: { type: [string, "null"], format: date-time }
        brief_version: { type: [integer, "null"], description: Project brief version at approval. }
        approved_by: { type: [string, "null"], format: uuid }
        approved_by_name: { type: [string, "null"] }
        approved_at: { type: [string, "null"], format: date-time }
        started_at: { type: [string, "null"], format: date-time }
        ended_at: { type: [string, "null"], format: date-time }
        report: { type: string }
        created_by: { type: [string, "null"], format: uuid }
        created_by_name: { type: [string, "null"] }
        started_by_agent: { type: boolean }
        runner:
          type: string
          enum: [builtin, external]
          description: "external: your own agent sends the Outflare-Mission header. builtin: Outflare's agent runs it in turns."
        turns: { $ref: "#/components/schemas/MissionTurns" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    MissionTurns:
      type: object
      description: The built-in agent's turns. All empty for an external runner.
      required: [count, last_at, next_at, summary, error]
      properties:
        count: { type: integer }
        last_at: { type: [string, "null"], format: date-time }
        next_at: { type: [string, "null"], format: date-time, description: When the next turn runs; null when none is scheduled. }
        summary: { type: string, description: What the agent said it did in its last turn. }
        error: { type: string, description: Why the last turn failed; empty when it went fine. Three failures in a row pause the mission. }
    MissionBrief:
      type: object
      required: [project_id, name, offer, audience, market, brief, icp, version, updated_at, changed_since_approval]
      properties:
        project_id: { type: string, format: uuid }
        name: { type: string }
        offer: { type: string }
        audience: { type: string }
        market: { type: string }
        brief: { type: string }
        icp: { type: object, additionalProperties: true }
        version: { type: integer }
        updated_at: { type: string, format: date-time }
        changed_since_approval: { type: boolean }
    MissionCall:
      type: object
      required: [id, actor_id, actor_name, method, path, query, outcome, status_code, duration_ms, reason, action_id, request_id, created_at]
      properties:
        id: { type: integer }
        actor_id: { type: [string, "null"], format: uuid }
        actor_name: { type: [string, "null"] }
        method: { type: string }
        path: { type: string }
        query: { type: string }
        outcome: { type: string, enum: [executed, simulated, queued, refused] }
        status_code: { type: integer }
        duration_ms: { type: integer }
        reason: { type: string }
        action_id: { type: [string, "null"], format: uuid }
        request_id: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    MissionAction:
      type: object
      required: [id, mission_id, mission_name, actor_id, actor_name, method, path, query, reason, summary, status, decided_by, decided_by_name, decided_at, decision_note, result_status, request_id, created_at]
      properties:
        id: { type: string, format: uuid }
        mission_id: { type: string, format: uuid }
        mission_name: { type: string }
        actor_id: { type: [string, "null"], format: uuid }
        actor_name: { type: [string, "null"] }
        method: { type: string }
        path: { type: string }
        query: { type: string }
        body: { description: The request body, as JSON when it was JSON. }
        reason: { type: string }
        summary: { type: string, example: Create sequence }
        status: { type: string, enum: [simulated, pending, executed, failed, rejected, canceled] }
        decided_by: { type: [string, "null"], format: uuid }
        decided_by_name: { type: [string, "null"] }
        decided_at: { type: [string, "null"], format: date-time }
        decision_note: { type: string }
        result_status: { type: [integer, "null"] }
        result: { description: The response when it ran. }
        request_id: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    Queued:
      type: object
      description: The work was queued and runs in the background.
      required: [queued]
      properties:
        queued: { type: boolean, enum: [true] }
    MissionHeld:
      type: object
      description: "Answer (202) to a write made in a mission that did not run: a dry run, or held for approval."
      required: [mission_action, dry_run, approval_required, message]
      properties:
        mission_action: { $ref: "#/components/schemas/MissionAction" }
        dry_run: { type: boolean }
        approval_required: { type: boolean }
        message: { type: string }
    MissionUpdate:
      type: object
      required: [id, author, actor_id, actor_name, kind, body, data, resolved_at, created_at]
      properties:
        id: { type: integer }
        author: { type: string, enum: [agent, person, system] }
        actor_id: { type: [string, "null"], format: uuid }
        actor_name: { type: [string, "null"] }
        kind: { type: string, enum: [note, progress, question, instruction, report, connect_request] }
        body: { type: string }
        data: { type: object, additionalProperties: true }
        resolved_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    ConnectorCategory:
      type: string
      enum: [email_verification, sequencer, mailbox_provider, dns, crm, data, signals, analytics, seo, calendar, registrar, warehouse, notification, model, custom]
    ConnectorField:
      type: object
      required: [name, label, secret, optional]
      properties:
        name: { type: string }
        label: { type: string }
        secret: { type: boolean }
        optional: { type: boolean }
        placeholder: { type: string }
        help: { type: string }
    Connector:
      type: object
      required: [id, name, category, summary, description, website, docs_url, auth, fields, capabilities, status, configured, connected]
      properties:
        id: { type: string, example: zerobounce }
        name: { type: string }
        category: { $ref: "#/components/schemas/ConnectorCategory" }
        summary: { type: string }
        description: { type: string }
        website: { type: string }
        docs_url: { type: string }
        key_url: { type: string, description: Where to find the API key. }
        auth: { type: string, enum: [api_key, oauth2, none] }
        fields: { type: array, items: { $ref: "#/components/schemas/ConnectorField" } }
        oauth:
          type: object
          properties:
            scopes: { type: array, items: { type: string } }
            client_metadata: { type: boolean, description: "The provider reads Outflare's sign-in details from a document this server hosts, so it only needs PUBLIC_URL on a public https address, no app to register." }
        capabilities: { type: array, items: { type: string }, example: [verify] }
        status: { type: string, enum: [available, planned], description: "planned: listed but not connectable yet." }
        pricing: { type: string }
        also_connects:
          type: array
          items: { type: string }
          description: Other connectors the same sign-in connects too, when the person grants them (one Google sign-in connects Google Analytics and Search Console).
        configured: { type: boolean, description: Can be connected on this server now. }
        connected: { type: integer, description: Accounts the workspace connected (0 in the public catalog). }
    ConnectorAccount:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        email: { type: string }
        credits: { type: number, description: Credits or quota left, when the service reports it. }
        details: { type: object, additionalProperties: { type: string } }
    ReportMetrics:
      type: object
      required: [sent, contacted, new_leads, follow_ups, failed, blocked, bounced, replies, replied_leads, auto_replies, opt_outs, unsubscribes, warmup_inbox, warmup_spam, warmup_missing, reply_rate, bounce_rate, opt_out_rate, inbox_placement, clicks, clicked_leads, click_rate]
      properties:
        sent: { type: integer }
        contacted: { type: integer, description: Distinct leads emailed. }
        new_leads: { type: integer, description: First steps sent. }
        follow_ups: { type: integer }
        failed: { type: integer }
        blocked: { type: integer, description: Stopped by the send policy before sending. }
        bounced: { type: integer }
        replies: { type: integer }
        replied_leads: { type: integer }
        auto_replies: { type: integer }
        opt_outs: { type: integer, description: Replies asking not to be contacted. }
        unsubscribes: { type: integer }
        warmup_inbox: { type: integer }
        warmup_spam: { type: integer }
        warmup_missing: { type: integer }
        reply_rate: { type: [number, "null"], description: Percent of contacted leads who replied. }
        bounce_rate: { type: [number, "null"] }
        opt_out_rate: { type: [number, "null"] }
        inbox_placement: { type: [number, "null"] }
        clicks: { type: integer, description: Tracked link clicks, leaving out likely link scanners. Zero unless click tracking is on. }
        clicked_leads: { type: integer }
        click_rate: { type: [number, "null"], description: Percent of contacted leads who clicked. }
    Report:
      type: object
      required: [from, to, group_by, totals, rows]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        group_by: { type: string }
        totals: { $ref: "#/components/schemas/ReportMetrics" }
        rows:
          type: array
          items:
            allOf:
              - { $ref: "#/components/schemas/ReportMetrics" }
              - type: object
                required: [key, label]
                properties:
                  key: { type: string }
                  label: { type: string }
    HealthSignal:
      type: object
      required: [key, level, title, detail]
      properties:
        key: { type: string, example: bounce_rate, description: "sign_in, inbox_sync, send_failures, bounce_rate, placement, warmup, domain_spf, domain_dkim, domain_dmarc, blocklist, domain_paused, paused" }
        level: { type: string, enum: [ok, warning, critical, paused] }
        title: { type: string }
        detail: { type: string }
        fix: { type: string }
        value: { type: number }
    MailboxHealth:
      type: object
      required: [mailbox_id, email, domain, provider, status, level, score, signals, stats]
      properties:
        mailbox_id: { type: string, format: uuid }
        email: { type: string }
        domain: { type: string }
        provider: { type: string }
        status: { $ref: "#/components/schemas/MailboxStatus" }
        level: { type: string, enum: [ok, warning, critical, paused] }
        score: { type: integer, description: 100 is healthy. }
        signals: { type: array, items: { $ref: "#/components/schemas/HealthSignal" } }
        stats:
          type: object
          required: [sent_24h, bounced_24h, sent_7d, bounced_7d, bounce_rate_7d, failed_24h, replies_7d, warmup_landed_7d, warmup_spam_7d, warmup_missing_7d, inbox_placement_7d]
          properties:
            sent_24h: { type: integer }
            bounced_24h: { type: integer }
            sent_7d: { type: integer }
            bounced_7d: { type: integer }
            bounce_rate_7d: { type: [number, "null"], description: Percent; null under 20 sends. }
            failed_24h: { type: integer }
            replies_7d: { type: integer }
            warmup_landed_7d: { type: integer }
            warmup_spam_7d: { type: integer }
            warmup_missing_7d: { type: integer }
            inbox_placement_7d: { type: [number, "null"], description: Percent of warm-up email in the inbox; null under 10. }
    MailboxHealthSummary:
      type: object
      required: [total, ok, warning, critical, paused]
      properties:
        total: { type: integer }
        ok: { type: integer }
        warning: { type: integer }
        critical: { type: integer }
        paused: { type: integer }
    HealthSettings:
      type: object
      required: [auto_pause, pause_bounce_rate, min_sends]
      properties:
        auto_pause: { type: boolean, description: Pause a mailbox when its 24-hour bounce rate reaches pause_bounce_rate. }
        pause_bounce_rate: { type: number, description: "Percent. Default 5." }
        min_sends: { type: integer, description: "Sends in 24 hours before the rate counts. Default 20." }
    ExternalStep:
      type: object
      required: [delay_days, subject, body]
      properties:
        delay_days: { type: integer, description: Days after the previous step. }
        subject: { type: string }
        body: { type: string }
        variants: { type: array, items: { type: string }, description: Other A/B bodies. }
    ExternalCampaign:
      type: object
      required: [id, name, status, steps, mailbox_emails, lead_count]
      properties:
        id: { type: string }
        name: { type: string }
        status: { type: string, description: "The other tool's word: active, paused, draft, completed." }
        timezone: { type: string }
        daily_limit: { type: integer }
        steps: { type: array, items: { $ref: "#/components/schemas/ExternalStep" } }
        mailbox_emails: { type: array, items: { type: string } }
        lead_count: { type: integer }
    ExternalMailbox:
      type: object
      required: [email, warmup_enabled]
      properties:
        email: { type: string }
        name: { type: string }
        provider: { type: string, description: "google, microsoft or smtp" }
        daily_limit: { type: integer }
        warmup_enabled: { type: boolean }
        status: { type: string }
    MailboxMatch:
      type: object
      required: [total, matched, missing]
      properties:
        total: { type: integer }
        matched: { type: integer, description: Already in this workspace. }
        missing: { type: array, items: { $ref: "#/components/schemas/ExternalMailbox" }, description: "Not here yet. Other tools never reveal passwords, so connect these (Google or Microsoft sign-in, or an SMTP/IMAP CSV) to send from them." }
    ImportPreview:
      type: object
      required: [campaigns, mailboxes]
      properties:
        campaigns: { type: array, items: { $ref: "#/components/schemas/ExternalCampaign" } }
        mailboxes: { $ref: "#/components/schemas/MailboxMatch" }
    ImportRun:
      type: object
      required: [id, integration_id, connector, options, status, summary, error, created_at, started_at, finished_at]
      properties:
        id: { type: string, format: uuid }
        integration_id: { type: string, format: uuid }
        connector: { type: string }
        options:
          type: object
          properties:
            campaign_ids: { type: array, items: { type: string } }
            leads: { type: boolean }
            mailboxes: { type: boolean }
        status: { type: string, enum: [queued, running, done, failed] }
        summary:
          type: object
          required: [campaigns, leads]
          properties:
            campaigns:
              type: array
              items:
                type: object
                required: [external_id, name, status, sequence_id, created, steps, leads, enrolled, warnings]
                properties:
                  external_id: { type: string }
                  name: { type: string }
                  status: { type: string }
                  sequence_id: { type: string }
                  created: { type: boolean, description: False when an earlier import's sequence was reused. }
                  steps: { type: integer }
                  leads: { type: integer }
                  enrolled: { type: integer }
                  warnings: { type: array, items: { type: string } }
            mailboxes: { $ref: "#/components/schemas/MailboxMatch" }
            leads:
              type: object
              required: [seen, created, updated, suppressed, enrolled, skipped]
              properties:
                seen: { type: integer }
                created: { type: integer }
                updated: { type: integer }
                suppressed: { type: integer }
                enrolled: { type: integer }
                skipped: { type: integer, description: "Replied, finished, unsubscribed, bounced, or past the last step." }
        error: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, "null"], format: date-time }
        finished_at: { type: [string, "null"], format: date-time }
    ConnectRequest:
      type: object
      required: [id, connector, connector_name, name, reason, use_for_verification, status, integration_id, created_by_type, expires_at, completed_at, created_at]
      properties:
        id: { type: string, format: uuid }
        connector: { type: string }
        connector_name: { type: string }
        name: { type: string }
        reason: { type: string }
        use_for_verification: { type: boolean }
        status: { type: string, enum: [pending, connected, cancelled, expired] }
        integration_id: { type: [string, "null"], format: uuid }
        created_by_type: { type: string, description: "human or api_token" }
        expires_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        url: { type: string, description: The one-time link. Only in the answer to POST. }
    Integration:
      type: object
      required: [id, connector, connector_name, category, capabilities, name, status, account, config, last_checked_at, last_error, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        connector: { type: string }
        connector_name: { type: string }
        category: { $ref: "#/components/schemas/ConnectorCategory" }
        capabilities: { type: array, items: { type: string } }
        name: { type: string }
        status: { type: string, enum: [connected, error], description: "error: the service refused the credentials; see last_error." }
        account: { $ref: "#/components/schemas/ConnectorAccount" }
        config: { type: object, additionalProperties: true }
        last_checked_at: { type: [string, "null"], format: date-time }
        last_error: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    AnalyticsReportName:
      type: string
      enum: [traffic, pages, sources, search_queries, events]
    AnalyticsSource:
      type: object
      required: [id, name, connector, connector_name, status, last_error, account, site, reports]
      properties:
        id: { type: string, format: uuid, description: The integration id. }
        name: { type: string }
        connector: { type: string, example: google_analytics }
        connector_name: { type: string }
        status: { type: string, enum: [connected, error] }
        last_error: { type: [string, "null"] }
        account: { $ref: "#/components/schemas/ConnectorAccount" }
        site: { type: string, description: "The default site; empty until one is picked, unless the account has only one." }
        reports: { type: array, items: { $ref: "#/components/schemas/AnalyticsReportName" } }
    AnalyticsSite:
      type: object
      required: [id, name]
      properties:
        id: { type: string, example: "123456789" }
        name: { type: string, example: "acme.com (Acme)" }
        url: { type: string }
    AnalyticsMetrics:
      type: object
      description: Every field is optional. A missing field means the provider does not measure it; zero means it measured none.
      properties:
        visitors: { type: number, description: Unique visitors or users. }
        pageviews: { type: number }
        sessions: { type: number, description: Visits. }
        bounce_rate: { type: number, description: A fraction from 0 to 1. }
        avg_duration: { type: number, description: Average visit length in seconds. }
        clicks: { type: number, description: Clicks from search results. }
        impressions: { type: number, description: Times shown in search results. }
        ctr: { type: number, description: "Click-through rate, 0 to 1." }
        position: { type: number, description: Average position in search results; 1 is the top. }
        events: { type: number }
        conversions: { type: number, description: "Events that count as conversions: GA4 key events, Plausible goals, Matomo goals, Fathom events." }
    AnalyticsRow:
      allOf:
        - $ref: "#/components/schemas/AnalyticsMetrics"
        - type: object
          required: [key]
          properties:
            key: { type: string, description: "A page path, a source, a search query or an event name. (none) means not set." }
    AnalyticsPoint:
      allOf:
        - $ref: "#/components/schemas/AnalyticsMetrics"
        - type: object
          required: [date]
          properties:
            date: { type: string, format: date }
    AnalyticsReport:
      type: object
      required: [report, site, from, to, totals, notes, integration_id, connector, cached, fetched_at]
      properties:
        report: { $ref: "#/components/schemas/AnalyticsReportName" }
        site: { type: string }
        from: { type: string, format: date }
        to: { type: string, format: date, description: "Clarity only covers the last three days, so its range may differ from the one asked for." }
        totals: { $ref: "#/components/schemas/AnalyticsMetrics" }
        series: { type: array, items: { $ref: "#/components/schemas/AnalyticsPoint" }, description: The traffic report's days. }
        rows: { type: array, items: { $ref: "#/components/schemas/AnalyticsRow" }, description: A breakdown report's rows, biggest first. }
        notes: { type: array, items: { type: string } }
        integration_id: { type: string, format: uuid }
        connector: { type: string }
        cached: { type: boolean }
        fetched_at: { type: string, format: date-time }
    AnalyticsHistoryRow:
      allOf:
        - $ref: "#/components/schemas/AnalyticsMetrics"
        - type: object
          properties:
            day: { type: string, format: date }
            key: { type: string }
            integration_id: { type: string, format: uuid }
            connector: { type: string }
            host: { type: string }
    AnalyticsSync:
      type: object
      required: [integration_id, name, connector, site, host, first_day, last_day, last_run_at, last_ok_at, last_error, paused]
      properties:
        integration_id: { type: string, format: uuid }
        name: { type: string }
        connector: { type: string }
        site: { type: string }
        host: { type: string, description: "The website's domain; empty when the tool shows none. Set it with PUT .../analytics/{id}/site." }
        first_day: { type: [string, "null"], format: date }
        last_day: { type: [string, "null"], format: date }
        last_run_at: { type: [string, "null"], format: date-time }
        last_ok_at: { type: [string, "null"], format: date-time }
        last_error: { type: [string, "null"] }
        paused: { type: boolean }
    AnalyticsChange:
      type: object
      description: One metric now and in the period before. before, change and change_pct are missing when nothing was stored before; change_pct also when before was 0.
      required: [metric, now]
      properties:
        metric: { type: string }
        now: { type: number }
        before: { type: number }
        change: { type: number }
        change_pct: { type: number }
    AnalyticsWindow:
      type: object
      required: [from, to, days_stored]
      properties:
        from: { type: string, format: date }
        to: { type: string, format: date }
        days_stored: { type: integer }
    AnalyticsGap:
      type: object
      description: Something missing for a full picture, and how to fix it. connectors are ids for a connect request.
      required: [what, why, action]
      properties:
        what: { type: string }
        why: { type: string }
        connectors: { type: array, items: { type: string } }
        integration_id: { type: string, format: uuid }
        action: { type: string }
    AnalyticsMover:
      type: object
      required: [key, now, before, change]
      properties:
        key: { type: string }
        now: { type: number }
        before: { type: number }
        change: { type: number }
    AnalyticsWebsiteChanges:
      type: object
      required: [host, headline, tools]
      properties:
        host: { type: string, description: "The domain; empty for tools whose site shows none." }
        headline: { type: array, items: { type: string }, description: Plain sentences, each naming its tool and dates. }
        tools:
          type: array
          items:
            type: object
            required: [integration_id, name, connector, now, before, metrics]
            properties:
              integration_id: { type: string, format: uuid }
              name: { type: string }
              connector: { type: string }
              now: { $ref: "#/components/schemas/AnalyticsWindow" }
              before: { $ref: "#/components/schemas/AnalyticsWindow" }
              metrics: { type: array, items: { $ref: "#/components/schemas/AnalyticsChange" } }
        search: { type: array, description: Clicks and impressions added up across search consoles, when there are several., items: { $ref: "#/components/schemas/AnalyticsChange" } }
        movers:
          type: array
          items:
            type: object
            required: [report, tools, metric, up, down]
            properties:
              report: { type: string }
              tools: { type: array, items: { type: string } }
              metric: { type: string }
              up: { type: array, items: { $ref: "#/components/schemas/AnalyticsMover" } }
              down: { type: array, items: { $ref: "#/components/schemas/AnalyticsMover" } }
        unusual_days:
          type: array
          items:
            type: object
            required: [day, connector, name, metric, value, typical, what]
            properties:
              day: { type: string, format: date }
              connector: { type: string }
              name: { type: string }
              metric: { type: string }
              value: { type: number }
              typical: { type: number, description: Median of the 28 days before the period. }
              what: { type: string }
        seo:
          type: array
          items:
            type: object
            required: [integration_id, name, connector, day, metrics]
            properties:
              integration_id: { type: string, format: uuid }
              name: { type: string }
              connector: { type: string }
              day: { type: string, format: date }
              previous_day: { type: string, format: date }
              metrics: { type: array, items: { $ref: "#/components/schemas/AnalyticsChange" } }
              keywords_up: { type: array, items: { $ref: "#/components/schemas/SEOKeywordMove" } }
              keywords_down: { type: array, items: { $ref: "#/components/schemas/SEOKeywordMove" } }
              keywords_new: { type: integer }
              keywords_lost: { type: integer }
        gaps: { type: array, items: { $ref: "#/components/schemas/AnalyticsGap" } }
    SEOKeywordMove:
      type: object
      required: [keyword, position, previous]
      properties:
        keyword: { type: string }
        position: { type: number }
        previous: { type: number }
        volume: { type: number }
    AnalyticsCatalogSite:
      type: object
      required: [host, tools, seo, gaps]
      properties:
        host: { type: string }
        tools:
          type: array
          items:
            type: object
            required: [integration_id, name, connector, status, measures]
            properties:
              integration_id: { type: string, format: uuid }
              name: { type: string }
              connector: { type: string }
              site: { type: string }
              status: { type: string }
              measures: { type: object, description: "Per report, the metrics stored in the last 35 days.", additionalProperties: { type: array, items: { type: string } } }
              first_day: { type: string, format: date }
              last_day: { type: string, format: date }
              last_synced_at: { type: string, format: date-time }
              error: { type: string }
        seo:
          type: array
          items:
            type: object
            required: [integration_id, name, connector, reports, snapshots, budget_left, budget_unit]
            properties:
              integration_id: { type: string, format: uuid }
              name: { type: string }
              connector: { type: string }
              reports: { type: array, items: { type: string } }
              snapshots: { type: integer }
              first_snapshot: { type: string, format: date }
              last_snapshot: { type: string, format: date }
              budget_left: { type: number }
              budget_unit: { type: string }
        gaps: { type: array, items: { $ref: "#/components/schemas/AnalyticsGap" } }
    AnalyticsOverviewSite:
      type: object
      required: [host, tools, search, seo]
      properties:
        host: { type: string, description: "The domain; empty groups tools whose site has no domain yet." }
        tools:
          type: array
          items:
            type: object
            required: [integration_id, name, connector, site, totals, first_day, last_day, last_synced_at]
            properties:
              integration_id: { type: string, format: uuid }
              name: { type: string }
              connector: { type: string }
              site: { type: string }
              totals: { $ref: "#/components/schemas/AnalyticsMetrics" }
              first_day: { type: [string, "null"], format: date }
              last_day: { type: [string, "null"], format: date }
              last_synced_at: { type: [string, "null"], format: date-time }
        search: { $ref: "#/components/schemas/AnalyticsMetrics" }
        seo: { type: array, items: { $ref: "#/components/schemas/SEOLatest" }, description: "Each SEO connection's latest snapshot of the domain." }
    SEOReportName:
      type: string
      enum: [overview, keywords, backlinks, competitors, audit]
    SEOMetrics:
      type: object
      description: Every field is optional; a missing field is not measured by the provider.
      properties:
        domain_rating: { type: number, description: "Link strength, 0 to 100: Ahrefs DR, or DataForSEO rank on a 0 to 100 scale (not the same measure)." }
        backlinks: { type: number }
        referring_domains: { type: number }
        organic_keywords: { type: number, description: Keywords ranking in the top 100. }
        top3: { type: number, description: Keywords ranking in the top 3. }
        organic_traffic: { type: number, description: The provider's estimate of monthly visits from search. }
        traffic_value: { type: number, description: What that traffic would cost in ads, in US dollars. }
        position: { type: number, description: "A keyword's position; 1 is the top. For a competitor, its average position." }
        volume: { type: number, description: Monthly searches. }
        traffic: { type: number, description: Estimated monthly visits from this keyword. }
        common_keywords: { type: number, description: Keywords a competitor shares with the domain. }
        health_score: { type: number, description: "Site audit health, 0 to 100." }
        pages: { type: number, description: "Pages crawled, or pages an audit issue affects." }
    SEORow:
      allOf:
        - $ref: "#/components/schemas/SEOMetrics"
        - type: object
          required: [key]
          properties:
            key: { type: string, description: "A keyword, linking domain, competitor domain or audit issue." }
            url: { type: string, description: A keyword's ranking page. }
            kind: { type: string, enum: [error, warning, notice], description: An audit issue's importance. }
    SEOCost:
      type: object
      properties:
        usd: { type: number, description: "Dollars, as DataForSEO reported them." }
        units: { type: number, description: Ahrefs API units. }
        estimated: { type: boolean, description: "Worked out from the provider's price list, because it does not report it." }
    SEOBudget:
      type: object
      required: [unit, limit, spent, default]
      properties:
        unit: { type: string, enum: [USD, API unit] }
        limit: { type: number }
        spent: { type: number, description: Spent this calendar month (UTC). }
        default: { type: boolean }
    SEOAnswer:
      type: object
      required: [report, target, country, totals, notes, cost, integration_id, connector, cached, fetched_at, budget]
      properties:
        report: { $ref: "#/components/schemas/SEOReportName" }
        target: { type: string }
        country: { type: string }
        totals: { $ref: "#/components/schemas/SEOMetrics" }
        rows: { type: array, items: { $ref: "#/components/schemas/SEORow" } }
        notes: { type: array, items: { type: string } }
        cost: { $ref: "#/components/schemas/SEOCost" }
        integration_id: { type: string, format: uuid }
        connector: { type: string }
        cached: { type: boolean, description: A kept answer under a day old; it cost nothing. }
        fetched_at: { type: string, format: date-time }
        budget: { $ref: "#/components/schemas/SEOBudget" }
    SEOSource:
      type: object
      required: [id, name, connector, connector_name, status, last_error, account, reports, country, sync, domains_set, domains, budget]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        connector: { type: string }
        connector_name: { type: string }
        status: { type: string }
        last_error: { type: [string, "null"] }
        account: { $ref: "#/components/schemas/ConnectorAccount" }
        reports: { type: array, items: { $ref: "#/components/schemas/SEOReportName" } }
        country: { type: string }
        sync: { type: string, enum: [weekly, "off"] }
        domains_set: { type: boolean, description: "false: the domains follow the analytics tools." }
        domains:
          type: array
          items:
            type: object
            required: [host, last_run_at, last_ok_at, last_error, last_cost]
            properties:
              host: { type: string }
              last_run_at: { type: [string, "null"], format: date-time }
              last_ok_at: { type: [string, "null"], format: date-time }
              last_error: { type: [string, "null"] }
              last_cost: { type: [number, "null"] }
        budget: { $ref: "#/components/schemas/SEOBudget" }
    SEOHistoryRow:
      allOf:
        - $ref: "#/components/schemas/SEOMetrics"
        - type: object
          required: [day, integration_id, connector, host, country]
          properties:
            day: { type: string, format: date }
            integration_id: { type: string, format: uuid }
            connector: { type: string }
            host: { type: string }
            country: { type: string }
            key: { type: string }
            url: { type: string }
            kind: { type: string }
            previous: { $ref: "#/components/schemas/SEOMetrics" }
    SEOLatest:
      allOf:
        - $ref: "#/components/schemas/SEOMetrics"
        - type: object
          required: [integration_id, name, connector, day]
          properties:
            integration_id: { type: string, format: uuid }
            name: { type: string }
            connector: { type: string }
            day: { type: string, format: date }
    AnalyticsSummaryItem:
      allOf:
        - $ref: "#/components/schemas/AnalyticsSource"
        - type: object
          required: [report, totals, notes, error]
          properties:
            report: { type: string }
            totals:
              oneOf:
                - $ref: "#/components/schemas/AnalyticsMetrics"
                - type: "null"
            from: { type: string, format: date }
            to: { type: string, format: date }
            notes: { type: array, items: { type: string } }
            error: { type: [string, "null"] }
    VerificationStatus:
      type: string
      enum: [deliverable, undeliverable, risky, unknown]
    VerificationSettings:
      type: object
      required: [integration_id, provider, verify_on_import, verify_before_send, block_risky, block_catch_all, block_unknown, max_age_days]
      properties:
        integration_id: { type: [string, "null"], description: Null uses the free built-in checks. }
        provider: { type: string, description: "The connector id, or builtin." }
        verify_on_import: { type: boolean }
        verify_before_send: { type: boolean, description: "Hold a lead's first email until its address has a fresh result." }
        block_risky: { type: boolean }
        block_catch_all: { type: boolean }
        block_unknown: { type: boolean }
        max_age_days: { type: integer, description: Results older than this are checked again before a first email. }
    Verification:
      type: object
      required: [email, status, reason, catch_all, disposable, role, free, provider, provider_status, integration_id, checked_at]
      properties:
        email: { type: string }
        status: { $ref: "#/components/schemas/VerificationStatus" }
        reason: { type: string }
        catch_all: { type: boolean }
        disposable: { type: boolean }
        role: { type: boolean }
        free: { type: boolean }
        suggestion: { type: string, description: "A likely correction, e.g. for a misspelt domain." }
        score: { type: integer, minimum: 0, maximum: 100 }
        provider: { type: string, description: "Connector id that answered, or builtin." }
        provider_status: { type: string, description: The provider's own status words. }
        integration_id: { type: [string, "null"] }
        checked_at: { type: string, format: date-time }
    VerificationSummary:
      type: object
      required: [leads, verified, stale, unverified, deliverable, undeliverable, risky, catch_all, unknown, pending, settings]
      properties:
        leads: { type: integer }
        verified: { type: integer, description: Leads with a fresh result. }
        stale: { type: integer, description: Leads whose result is older than max_age_days. }
        unverified: { type: integer }
        deliverable: { type: integer }
        undeliverable: { type: integer }
        risky: { type: integer }
        catch_all: { type: integer }
        unknown: { type: integer }
        pending: { type: integer, description: Addresses waiting to be verified. }
        settings: { $ref: "#/components/schemas/VerificationSettings" }
    MeetingStatus:
      type: string
      enum: [booked, held, no_show, canceled]
    Attribution:
      type: string
      enum: [lead, company, manual, none]
      description: "How it was tied to outreach. lead: an attendee is a lead. company: an attendee shares a lead's company domain and that lead was emailed. manual: someone said so. none: not tied (not counted in reports)."
    Meeting:
      type: object
      required: [id, project_id, sequence_id, mission_id, lead_id, inbound_id, source, integration_id, external_id, title, starts_at, ends_at, booked_at, status, attendee_email, attendee_name, organizer_email, attribution, attribution_pinned, qualified, notes, created_by, created_at, updated_at, lead_email, sequence_name, project_name]
      properties:
        id: { type: string, format: uuid }
        project_id: { type: [string, "null"], format: uuid }
        sequence_id: { type: [string, "null"], format: uuid }
        mission_id: { type: [string, "null"], format: uuid }
        lead_id: { type: [string, "null"], format: uuid }
        inbound_id: { type: [string, "null"], format: uuid, description: The reply that led to the meeting. }
        source: { type: string, enum: [google_calendar, microsoft_calendar, calendly, cal_com, hubspot, manual, api] }
        integration_id: { type: [string, "null"], format: uuid }
        external_id: { type: [string, "null"] }
        title: { type: string }
        starts_at: { type: string, format: date-time }
        ends_at: { type: [string, "null"], format: date-time }
        booked_at: { type: string, format: date-time }
        status: { $ref: "#/components/schemas/MeetingStatus" }
        attendee_email: { type: string }
        attendee_name: { type: string }
        organizer_email: { type: string }
        attribution: { $ref: "#/components/schemas/Attribution" }
        attribution_pinned: { type: boolean, description: Set by a person; syncs never change it. }
        qualified: { type: [boolean, "null"] }
        notes: { type: string }
        created_by: { type: [string, "null"], format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        lead_email: { type: [string, "null"] }
        sequence_name: { type: [string, "null"] }
        project_name: { type: [string, "null"] }
    NewMeeting:
      type: object
      required: [starts_at]
      properties:
        lead_id: { type: string, format: uuid }
        attendee_email: { type: string }
        attendee_name: { type: string }
        sequence_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        mission_id: { type: string, format: uuid }
        title: { type: string }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        booked_at: { type: string, format: date-time, description: Default now. Decides which sequence gets the credit. }
        status: { $ref: "#/components/schemas/MeetingStatus" }
        qualified: { type: boolean }
        notes: { type: string }
        external_id: { type: string }
    MeetingPatch:
      type: object
      properties:
        status: { $ref: "#/components/schemas/MeetingStatus" }
        qualified: { type: boolean }
        notes: { type: string }
        title: { type: string }
        starts_at: { type: string, format: date-time }
        sequence_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        mission_id: { type: string, description: An id, or empty to clear. }
        unattributed: { type: boolean, description: Not from outreach; pins that. }
    Deal:
      type: object
      required: [id, project_id, sequence_id, mission_id, lead_id, meeting_id, source, integration_id, external_id, name, stage, state, amount_cents, currency, opened_at, closed_at, attribution, attribution_pinned, created_at, updated_at, lead_email, sequence_name, project_name]
      properties:
        id: { type: string, format: uuid }
        project_id: { type: [string, "null"], format: uuid }
        sequence_id: { type: [string, "null"], format: uuid }
        mission_id: { type: [string, "null"], format: uuid }
        lead_id: { type: [string, "null"], format: uuid }
        meeting_id: { type: [string, "null"], format: uuid }
        source: { type: string, enum: [hubspot, manual, api] }
        integration_id: { type: [string, "null"], format: uuid }
        external_id: { type: [string, "null"] }
        name: { type: string }
        stage: { type: string }
        state: { type: string, enum: [open, won, lost] }
        amount_cents: { type: [integer, "null"] }
        currency: { type: string }
        opened_at: { type: string, format: date-time }
        closed_at: { type: [string, "null"], format: date-time }
        attribution: { $ref: "#/components/schemas/Attribution" }
        attribution_pinned: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        lead_email: { type: [string, "null"] }
        sequence_name: { type: [string, "null"] }
        project_name: { type: [string, "null"] }
    NewDeal:
      type: object
      required: [name]
      properties:
        name: { type: string }
        lead_id: { type: string, format: uuid }
        contact_email: { type: string }
        meeting_id: { type: string, format: uuid }
        sequence_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        mission_id: { type: string, format: uuid }
        stage: { type: string }
        state: { type: string, enum: [open, won, lost], description: Default open. }
        amount_cents: { type: integer, minimum: 0 }
        currency: { type: string, description: Default USD. }
        opened_at: { type: string, format: date-time }
        closed_at: { type: string, format: date-time }
        external_id: { type: string }
    DealPatch:
      type: object
      properties:
        name: { type: string }
        stage: { type: string }
        state: { type: string, enum: [open, won, lost] }
        amount_cents: { type: integer, minimum: 0 }
        currency: { type: string }
        closed_at: { type: string, format: date-time }
        sequence_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        mission_id: { type: string }
        unattributed: { type: boolean }
    SpendCategory:
      type: string
      enum: [data, verification, mailboxes, domains, sending, software, people, ads, other]
    SpendEntry:
      type: object
      required: [id, project_id, sequence_id, mission_id, mailbox_id, domain, category, provider, integration_id, units, unit_name, amount_cents, currency, occurred_at, source, ref, note, actor_id, created_at]
      properties:
        id: { type: string, format: uuid }
        project_id: { type: [string, "null"], format: uuid }
        sequence_id: { type: [string, "null"], format: uuid }
        mission_id: { type: [string, "null"], format: uuid }
        mailbox_id: { type: [string, "null"], format: uuid }
        domain: { type: string }
        category: { $ref: "#/components/schemas/SpendCategory" }
        provider: { type: string }
        integration_id: { type: [string, "null"], format: uuid }
        units: { type: [number, "null"] }
        unit_name: { type: string }
        amount_cents: { type: [integer, "null"], description: Null when only units are known. }
        currency: { type: string }
        occurred_at: { type: string, format: date-time }
        source: { type: string, enum: [auto, manual, import] }
        ref: { type: [string, "null"] }
        note: { type: string }
        actor_id: { type: [string, "null"], format: uuid, description: The person, token or agent that caused it. }
        created_at: { type: string, format: date-time }
    NewSpend:
      type: object
      required: [category]
      properties:
        category: { $ref: "#/components/schemas/SpendCategory" }
        provider: { type: string, example: Apollo }
        amount_cents: { type: integer, minimum: 0 }
        units: { type: number, minimum: 0 }
        unit_name: { type: string, example: credit }
        currency: { type: string, description: Default USD. }
        occurred_at: { type: string, format: date-time }
        project_id: { type: string, format: uuid }
        sequence_id: { type: string, format: uuid }
        mission_id: { type: string, format: uuid }
        mailbox_id: { type: string, format: uuid }
        domain: { type: string, description: A sending domain. }
        integration_id: { type: string, format: uuid }
        ref: { type: string, description: "Idempotency key, e.g. apollo:run:123." }
        note: { type: string }
    RecurringSpendInput:
      type: object
      required: [name, category, amount_cents]
      properties:
        name: { type: string, example: Google Workspace seats }
        category: { $ref: "#/components/schemas/SpendCategory" }
        provider: { type: string }
        amount_cents: { type: integer, minimum: 0 }
        currency: { type: string, description: Default USD. }
        every: { type: string, enum: [month, year], description: Default month. }
        starts_on: { type: string, format: date }
        ends_on: { type: [string, "null"], format: date }
        project_id: { type: [string, "null"], format: uuid }
        sequence_id: { type: [string, "null"], format: uuid }
        note: { type: string }
    RecurringSpend:
      allOf:
        - { $ref: "#/components/schemas/RecurringSpendInput" }
        - type: object
          required: [id, every, currency, starts_on, ends_on, project_id, sequence_id, provider, note, created_at, updated_at, daily_cents]
          properties:
            id: { type: string, format: uuid }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }
            daily_cents: { type: number, description: What it adds to a report per active day. }
    SpendRate:
      type: object
      required: [provider, unit_name, unit_price_micros, currency, updated_at, unpriced_units]
      properties:
        provider: { type: string }
        unit_name: { type: string }
        unit_price_micros: { type: integer, description: "Millionths of the currency unit. 0 with unpriced_units above 0: needs a price." }
        currency: { type: string }
        updated_at: { type: string, format: date-time }
        unpriced_units: { type: number, description: Units recorded with this provider and unit and no amount. }
    OutcomeSpend:
      type: object
      required: [total_cents, direct_cents, shared_cents, estimated_cents, recurring_cents, by_category]
      properties:
        total_cents: { type: integer }
        direct_cents: { type: integer, description: "Charged to this row, or through the mailboxes and domains its sends used." }
        shared_cents: { type: integer, description: "Its share of workspace-wide costs, by share of emails sent." }
        estimated_cents: { type: integer, description: Part of the total priced from units with the workspace's rates. }
        recurring_cents: { type: integer }
        by_category: { type: object, additionalProperties: { type: integer } }
    OutcomeRow:
      type: object
      required: [key, name, sent, replies, reply_rate, meetings, held, no_shows, qualified, deals, won, pipeline_cents, won_cents, spend, cost_per_reply_cents, cost_per_meeting_cents, cost_per_held_meeting_cents, cost_per_deal_cents, cost_per_won_deal_cents, roi]
      properties:
        key: { type: string, description: "The project or sequence id; empty for outcomes in no group; all for the workspace." }
        name: { type: string }
        sent: { type: integer }
        replies: { type: integer }
        reply_rate: { type: [number, "null"] }
        meetings: { type: integer, description: "Booked in the period and tied to outreach, canceled ones left out." }
        held: { type: integer }
        no_shows: { type: integer }
        qualified: { type: integer }
        deals: { type: integer }
        won: { type: integer }
        pipeline_cents: { type: integer }
        won_cents: { type: integer }
        spend: { $ref: "#/components/schemas/OutcomeSpend" }
        cost_per_reply_cents: { type: [integer, "null"] }
        cost_per_meeting_cents: { type: [integer, "null"] }
        cost_per_held_meeting_cents: { type: [integer, "null"] }
        cost_per_deal_cents: { type: [integer, "null"] }
        cost_per_won_deal_cents: { type: [integer, "null"] }
        roi: { type: [number, "null"], description: Won revenue per unit of spend. }
    OutcomesReport:
      type: object
      required: [from, to, group_by, currency, totals, rows, unattributed_meetings, unpriced, other_currencies, notes]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        group_by: { type: string, enum: [none, project, sequence] }
        currency: { type: string }
        totals: { $ref: "#/components/schemas/OutcomeRow" }
        rows: { type: array, items: { $ref: "#/components/schemas/OutcomeRow" } }
        unattributed_meetings: { type: integer }
        unpriced:
          type: array
          items:
            type: object
            required: [provider, unit_name, units]
            properties:
              provider: { type: string }
              unit_name: { type: string }
              units: { type: number }
        other_currencies: { type: object, additionalProperties: { type: integer } }
        notes: { type: array, items: { type: string }, description: Plain-language caveats about the numbers. }
    OutcomeSync:
      type: object
      required: [integration_id, connector, name, stream, last_run_at, last_ok_at, last_error, last_changes, paused]
      properties:
        integration_id: { type: string, format: uuid }
        connector: { type: string }
        name: { type: string }
        stream: { type: string, description: "meetings, deals or push; empty before the first run." }
        last_run_at: { type: [string, "null"], format: date-time }
        last_ok_at: { type: [string, "null"], format: date-time }
        last_error: { type: [string, "null"] }
        last_changes: { type: integer }
        paused: { type: boolean }
    Role:
      type: string
      enum: [owner, admin, operator, viewer]
    Scope:
      type: string
      enum: [workspace.read, members.read, members.manage, tokens.manage, audit.read, mailboxes.read, mailboxes.manage, leads.read, leads.manage, suppressions.manage, sequences.read, sequences.manage, inbox.read, inbox.manage, integrations.read, integrations.manage, outcomes.read, outcomes.manage, projects.read, projects.manage, infrastructure.read, infrastructure.manage, infrastructure.approve, analytics.read, missions.read, missions.run, missions.approve]
    Workspace:
      type: object
      required: [id, name, slug]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        role: { $ref: "#/components/schemas/Role" }
        agency_id: { type: [string, "null"], format: uuid, description: Set when the workspace is an agency client. }
        brand:
          description: The agency's white label for a client workspace; null otherwise.
          oneOf:
            - { $ref: "#/components/schemas/Brand" }
            - { type: "null" }
    Brand:
      type: object
      required: [agency_name, name, logo_url, accent_color, support_email]
      properties:
        agency_name: { type: string }
        name: { type: string, description: Shown instead of Outflare when set. }
        logo_url: { type: string }
        accent_color: { type: string, example: "#2563eb" }
        support_email: { type: string }
    Agency:
      type: object
      required: [id, name, brand_name, logo_url, accent_color, support_email, clients, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        brand_name: { type: string }
        logo_url: { type: string }
        accent_color: { type: string }
        support_email: { type: string }
        clients: { type: integer }
        role: { $ref: "#/components/schemas/Role" }
        created_at: { type: string, format: date-time }
    AgencyClient:
      type: object
      required: [id, name, slug, mailboxes_active, mailboxes_paused, active_sequences, sent_7d, replies_7d, bounced_7d, bounce_rate_7d, reply_rate_7d, open_replies, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        mailboxes_active: { type: integer }
        mailboxes_paused: { type: integer }
        active_sequences: { type: integer }
        sent_7d: { type: integer }
        replies_7d: { type: integer }
        bounced_7d: { type: integer }
        bounce_rate_7d: { type: [number, "null"], description: Percent of sends that bounced, last 7 days. }
        reply_rate_7d: { type: [number, "null"], description: Percent of sends that got a reply, last 7 days. }
        open_replies: { type: integer, description: Replies not done or snoozed. }
        created_at: { type: string, format: date-time }
    AgencyToken:
      allOf:
        - { $ref: "#/components/schemas/APIToken" }
        - type: object
          properties:
            can_create_clients: { type: boolean }
    AgencyEvent:
      type: object
      properties:
        id: { type: string, format: uuid }
        actor: { type: [string, "null"] }
        action: { type: string, example: agency.client_added }
        target: { type: string }
        data: { type: object }
        created_at: { type: string, format: date-time }
    Member:
      type: object
      properties:
        user_id: { type: string, format: uuid }
        email: { type: string }
        name: { type: string }
        role: { $ref: "#/components/schemas/Role" }
        created_at: { type: string, format: date-time }
    Invite:
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string }
        role: { $ref: "#/components/schemas/Role" }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
    APIToken:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        prefix: { type: string, description: "First characters of the secret, to recognise it." }
        scopes:
          type: array
          items: { $ref: "#/components/schemas/Scope" }
        created_at: { type: string, format: date-time }
        expires_at: { type: [string, "null"], format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }
        project_id: { type: [string, "null"], format: uuid, description: "The project this token is bound to, or null for the whole workspace." }
        daily_send_budget: { type: [integer, "null"], description: Most emails a day across sequences this token started. Null is no budget. }
        max_priority: { type: [string, "null"], enum: [low, normal, high, null], description: Highest sequence priority this token may set. }
        may_take_mailboxes: { type: boolean, description: "Whether its sequences may take mailboxes from lower-priority sequences without the owner's approval. Off by default." }
        default_team_id: { type: [string, "null"], format: uuid, description: Team for sequences it creates without one. }
    APITokenLimits:
      type: object
      properties:
        daily_send_budget: { type: [integer, "null"], minimum: 1, maximum: 1000000 }
        max_priority: { type: [string, "null"], enum: [low, normal, high, null] }
        may_take_mailboxes: { type: boolean }
        default_team_id: { type: [string, "null"], format: uuid }
    AuditEvent:
      type: object
      properties:
        id: { type: integer }
        actor_id: { type: [string, "null"] }
        actor_type: { type: string }
        actor_name: { type: [string, "null"] }
        action: { type: string, example: member.role_changed }
        target_type: { type: [string, "null"] }
        target_id: { type: [string, "null"] }
        request_id: { type: [string, "null"] }
        data: { type: object }
        created_at: { type: string, format: date-time }
    MailboxStatus:
      type: string
      enum: [verifying, active, paused, error]
    Mailbox:
      type: object
      required: [id, email, domain, display_name, provider, auth_method, smtp_host, smtp_port, smtp_username, imap_host, imap_port, imap_username, status, status_reason, daily_limit, min_gap_seconds, smtp_ok, imap_ok, last_verified_at, last_synced_at, sync_error, source, pool_id, pool_name, warmup, sender, suggested_sender_id, assignment, lock, has_login, server_id, server_host, platforms, lifecycle, created_at, updated_at]
      properties:
        lifecycle:
          type: object
          required: [stage, reason, until, changed_at, auto_rest, plan_id]
          description: Where the mailbox is in its working life. Only ready mailboxes send cold email.
          properties:
            stage: { $ref: "#/components/schemas/LifecycleStage" }
            reason: { type: string }
            until: { type: [string, "null"], format: date-time, description: "Warming: the earliest it can be ready. Resting: when the rest ends." }
            changed_at: { type: string, format: date-time }
            auto_rest: { type: boolean }
            plan_id: { type: [string, "null"], format: uuid, description: The infrastructure plan that built it. }
        id: { type: string, format: uuid }
        email: { type: string }
        domain: { type: string }
        display_name: { type: string }
        provider: { type: string, enum: [google, microsoft, other] }
        project_id: { type: [string, "null"], format: uuid, description: "The project that owns the mailbox; it then sends only for that project's sequences. Null: shared by the workspace." }
        auth_method: { type: string, enum: [password, oauth] }
        smtp_host: { type: string }
        smtp_port: { type: integer }
        smtp_username: { type: string }
        imap_host: { type: string }
        imap_port: { type: integer }
        imap_username: { type: string }
        status: { $ref: "#/components/schemas/MailboxStatus" }
        status_reason: { type: [string, "null"], description: Why the last connection check failed. }
        daily_limit: { type: integer, minimum: 1, maximum: 500 }
        min_gap_seconds: { type: integer, minimum: 0, maximum: 3600, description: Least time between two sends from this mailbox. }
        smtp_ok: { type: [boolean, "null"] }
        imap_ok: { type: [boolean, "null"] }
        last_verified_at: { type: [string, "null"], format: date-time }
        last_synced_at: { type: [string, "null"], format: date-time, description: When the inbox was last checked for new mail. }
        sync_error: { type: [string, "null"], description: Why the last inbox sync failed. }
        source: { type: string, example: csv }
        pool_id: { type: [string, "null"], format: uuid }
        pool_name: { type: [string, "null"] }
        server_id: { type: [string, "null"], format: uuid, description: "The sending server it signs in to; null for Google and Microsoft mailboxes." }
        server_host: { type: [string, "null"] }
        warmup: { $ref: "#/components/schemas/MailboxWarmup" }
        sender:
          type: [object, "null"]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            confirmed: { type: boolean }
        suggested_sender_id: { type: [string, "null"], format: uuid }
        assignment:
          type: [object, "null"]
          description: The sequence this mailbox sends for now. Null when free.
          properties:
            sequence_id: { type: string, format: uuid }
            sequence_name: { type: string }
            state: { type: string, enum: [active, draining] }
            next_sequence_id: { type: [string, "null"], format: uuid }
            source: { type: string, enum: [manual, automatic, external] }
            reason: { type: string }
            platform: { type: [string, "null"], description: The sequencer it sends through for this sequence. Null when Outflare sends. }
            confirmed: { type: boolean, description: False until the platform shows the mailbox on the campaign. }
        lock:
          type: [object, "null"]
          properties:
            kind: { type: string, enum: [sequence, team, manual_only] }
            sequence_id: { type: [string, "null"], format: uuid }
            team_id: { type: [string, "null"], format: uuid }
            until: { type: [string, "null"], format: date-time }
            note: { type: string }
        has_login:
          type: boolean
          description: False for a mailbox Outflare only knows from an outside sequencer. It can be allocated to that platform's campaigns, but Outflare cannot send from it, sync its inbox or warm it up.
        platforms:
          type: array
          description: What each connected sequencer account reports about it.
          items:
            type: object
            properties:
              integration_id: { type: string, format: uuid }
              platform: { type: string }
              status: { type: string, enum: [ok, paused, error, disconnected] }
              status_reason: { type: [string, "null"] }
              daily_limit: { type: [integer, "null"] }
              warmup_score: { type: [integer, "null"] }
              campaigns: { type: integer, description: Live campaigns it is on there. }
              observed_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    MailboxWarmup:
      type: object
      description: Warm-up settings and the last 7 days of results.
      required: [enabled, daily_target, reply_rate, started_at, today_target, sent_7d, inbox_7d, spam_7d, missing_7d, inbox_rate]
      properties:
        enabled: { type: boolean }
        daily_target: { type: integer, minimum: 1, maximum: 100, description: Warm-up emails a day once fully ramped up. }
        reply_rate: { type: integer, minimum: 0, maximum: 100, description: Percent of received warm-up emails that are answered. }
        started_at: { type: [string, "null"], format: date-time, description: When warm-up was last turned on. }
        today_target: { type: integer, description: Today's volume on the ramp; 0 when off. }
        sent_7d: { type: integer }
        inbox_7d: { type: integer, description: Sent warm-up mail found in the recipient's inbox. }
        spam_7d: { type: integer, description: Found in spam, and moved to the inbox. }
        missing_7d: { type: integer, description: Not found within two days. }
        inbox_rate: { type: [number, "null"], minimum: 0, maximum: 1, description: "inbox_7d over all placed messages; null until any are placed." }
    WarmupSettings:
      type: object
      description: Unset fields are left as they are.
      properties:
        enabled: { type: boolean }
        daily_target: { type: integer, minimum: 1, maximum: 100 }
        reply_rate: { type: integer, minimum: 0, maximum: 100 }
    WarmupDay:
      type: object
      required: [date, sent, inbox, spam, missing, replies]
      properties:
        date: { type: string, format: date }
        sent: { type: integer }
        inbox: { type: integer }
        spam: { type: integer }
        missing: { type: integer }
        replies: { type: integer, description: Warm-up replies this mailbox received. }
    MailboxInput:
      type: object
      required: [email]
      properties:
        email: { type: string }
        display_name: { type: string }
        provider: { type: string, enum: [google, microsoft, other] }
        password: { type: string, description: App password used for both SMTP and IMAP. }
        smtp_host: { type: string }
        smtp_port: { type: integer }
        smtp_username: { type: string }
        smtp_password: { type: string }
        imap_host: { type: string }
        imap_port: { type: integer }
        imap_username: { type: string }
        imap_password: { type: string }
        pool: { type: string, description: Pool name. Created if missing. }
        daily_limit: { type: integer, minimum: 1, maximum: 500 }
    ImportResult:
      type: object
      required: [created, updated, errors]
      properties:
        created: { type: integer }
        updated: { type: integer }
        errors:
          type: array
          items:
            type: object
            required: [line, error]
            properties:
              line: { type: integer, description: Line number in the file. }
              email: { type: string }
              error: { type: string }
    OAuthConnection:
      type: object
      required: [id, provider, name, details, mailbox_count, created_at]
      properties:
        id: { type: string, format: uuid }
        provider: { type: string, enum: [google, microsoft] }
        name: { type: string }
        details:
          type: object
          description: "Non-secret identifiers: client_email and client_id for Google, tenant_id and client_id for Microsoft."
          additionalProperties: { type: string }
        mailbox_count: { type: integer }
        created_at: { type: string, format: date-time }
    MailboxPool:
      type: object
      required: [id, name, mailboxes, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        mailboxes: { type: integer }
        created_at: { type: string, format: date-time }
    SendingDomain:
      type: object
      required: [domain, status, daily_limit, notes, source, health, issues, mx_provider, mx_hosts, spf, spf_record, dkim, dkim_selector, dmarc, dmarc_record, dmarc_policy, blocklists, checked_at, mailboxes, mailbox_counts, daily_capacity, sent_24h, sent_7d, replies_7d, bounces_7d, warmup, created_at, updated_at]
      properties:
        domain: { type: string, example: acme.com }
        status: { type: string, enum: [active, paused], description: "paused: no sequence or test sends from the domain's mailboxes." }
        daily_limit: { type: [integer, "null"], description: Cap on sends from all the domain's mailboxes together in 24 hours. Null is no domain cap. }
        project_id: { type: [string, "null"], format: uuid, description: "The project that owns the domain, or null when shared. Move it with POST /projects/{id}/add." }
        notes: { type: string }
        source: { type: string, enum: [mailbox, manual, plan], description: "mailbox: added when a mailbox on it was connected. manual: added by hand. plan: bought by an infrastructure plan." }
        health: { type: string, enum: [healthy, warning, critical, pending], description: The most severe issue; pending until the first check. }
        issues:
          type: array
          description: What to fix, most severe first.
          items: { $ref: "#/components/schemas/DomainIssue" }
        mx_provider: { type: [string, "null"], enum: [google, microsoft, other, none, null] }
        mx_hosts: { type: array, items: { type: string }, description: MX hosts by preference. }
        spf: { type: string, enum: [pass, missing, invalid, pending] }
        spf_record: { type: [string, "null"] }
        dkim: { type: string, enum: [pass, missing, pending] }
        dkim_selector: { type: [string, "null"] }
        dmarc: { type: string, enum: [pass, none_policy, missing, pending] }
        dmarc_record: { type: [string, "null"] }
        dmarc_policy: { type: [string, "null"], enum: [none, quarantine, reject, null] }
        blocklists:
          type: array
          items: { $ref: "#/components/schemas/BlocklistResult" }
        checked_at: { type: [string, "null"], format: date-time, description: When DNS and blocklists were last checked. Null until the first check. }
        mailboxes: { type: integer, description: Mailboxes on the domain. }
        mailbox_counts:
          type: object
          required: [active, error, paused, verifying]
          properties:
            active: { type: integer }
            error: { type: integer }
            paused: { type: integer }
            verifying: { type: integer }
        daily_capacity: { type: integer, description: Sum of the daily limits of active mailboxes, held to the domain cap. 0 while paused. }
        sent_24h: { type: integer }
        sent_7d: { type: integer }
        replies_7d: { type: integer, description: Replies from people to the domain's mailboxes. }
        bounces_7d: { type: integer }
        warmup:
          type: object
          description: Warm-up across the domain's mailboxes, last 7 days.
          required: [warming, placed_7d, inbox_7d, inbox_rate]
          properties:
            warming: { type: integer, description: Active mailboxes with warm-up on. }
            placed_7d: { type: integer, description: Warm-up emails whose landing place is known. }
            inbox_7d: { type: integer }
            inbox_rate: { type: [number, "null"], minimum: 0, maximum: 1 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SendingDomainDetail:
      allOf:
        - { $ref: "#/components/schemas/SendingDomain" }
        - type: object
          required: [daily]
          properties:
            daily:
              type: array
              description: The last 14 days by UTC date, oldest first.
              items:
                type: object
                required: [date, sent, replies, bounces]
                properties:
                  date: { type: string, format: date }
                  sent: { type: integer }
                  replies: { type: integer }
                  bounces: { type: integer }
    DomainIssue:
      type: object
      required: [code, severity, title, detail]
      properties:
        code:
          type: string
          description: Stable identifier for agents.
          enum: [paused, no_mailboxes, mailboxes_failing, blocklisted, bounce_rate, spf_missing, spf_multiple, spf_provider, spf_permissive, dkim_missing, dmarc_missing, dmarc_none, mx_missing]
        severity: { type: string, enum: [critical, warning, info] }
        title: { type: string }
        detail: { type: string, description: Why it matters and how to fix it. }
        record:
          type: object
          description: A DNS record to publish, when the fix is one.
          required: [type, host, value]
          properties:
            type: { type: string, example: TXT }
            host: { type: string, description: "Relative to the domain: @ is the domain itself.", example: _dmarc }
            value: { type: string, example: "v=DMARC1; p=none" }
    SendingServer:
      type: object
      required: [id, host, name, ips, ips_source, status, paused_by, status_reason, pause_on_listing, daily_limit, notes, health, issues, blocklists, checked_at, mailboxes, mailbox_counts, domains, daily_capacity, sent_24h, sent_7d, replies_7d, bounces_7d, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        host: { type: string, example: smtp.mailreef-box.example, description: The SMTP host its mailboxes sign in to. }
        name: { type: string, description: A label you choose; empty by default. }
        ips: { type: array, items: { type: string }, description: Addresses mail leaves from. }
        ips_source: { type: string, enum: [dns, manual], description: "dns: the host's A and AAAA records, refreshed on every check. manual: set by hand." }
        status: { type: string, enum: [active, paused], description: "paused: no sequence or test sends from its mailboxes." }
        paused_by: { type: [string, "null"], enum: [person, blocklist, null], description: "blocklist: paused by a check that found a listed IP; lifts itself once every IP is clear." }
        status_reason: { type: [string, "null"], description: Why it was paused automatically. }
        pause_on_listing: { type: boolean }
        daily_limit: { type: [integer, "null"], description: Cap on sends from all its mailboxes together in 24 hours. Null is no server cap. }
        notes: { type: string }
        health: { type: string, enum: [healthy, warning, critical, pending], description: The most severe issue; pending until the first check. }
        issues:
          type: array
          description: What to fix, most severe first.
          items: { $ref: "#/components/schemas/ServerIssue" }
        blocklists:
          type: array
          description: Each IP on each IP blocklist (Spamhaus ZEN, SpamCop, Barracuda). IPv6 addresses are reported as unknown for now.
          items:
            allOf:
              - { $ref: "#/components/schemas/BlocklistResult" }
              - type: object
                required: [ip]
                properties:
                  ip: { type: string }
        checked_at: { type: [string, "null"], format: date-time, description: When IPs and blocklists were last checked. Null until the first check. }
        mailboxes: { type: integer, description: Mailboxes that sign in to it. }
        mailbox_counts:
          type: object
          required: [active, error, paused, verifying]
          properties:
            active: { type: integer }
            error: { type: integer }
            paused: { type: integer }
            verifying: { type: integer }
        domains: { type: array, items: { type: string }, description: Domains of its mailboxes. }
        daily_capacity: { type: integer, description: Sum of the daily limits of active mailboxes, held to the server cap. 0 while paused. }
        sent_24h: { type: integer }
        sent_7d: { type: integer }
        replies_7d: { type: integer }
        bounces_7d: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ServerIssue:
      type: object
      required: [code, severity, title, detail]
      properties:
        code:
          type: string
          description: Stable identifier for agents.
          enum: [paused, no_mailboxes, mailboxes_failing, no_ips, ip_blocklisted, bounce_rate, no_server_limit]
        severity: { type: string, enum: [critical, warning, info] }
        title: { type: string }
        detail: { type: string, description: Why it matters and how to fix it. }
    BlocklistResult:
      type: object
      required: [name, zone, status]
      properties:
        name: { type: string, example: Spamhaus DBL }
        zone: { type: string, example: dbl.spamhaus.org }
        status: { type: string, enum: [clear, listed, unknown], description: "unknown: the list refused the query, often because the server uses a public DNS resolver." }
        detail: { type: string }
    Lead:
      type: object
      required: [id, email, domain, first_name, last_name, company, title, phone, linkedin_url, website, custom, source, suppressed, verification, list_ids, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        email: { type: string }
        domain: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        company: { type: string }
        title: { type: string }
        phone: { type: string }
        linkedin_url: { type: string }
        website: { type: string }
        custom:
          type: object
          description: Extra imported columns, keyed by snake_case header.
          additionalProperties: { type: string }
        source: { type: string, example: csv }
        suppressed: { type: boolean, description: On the suppression list by address or domain. }
        verification:
          type: [string, "null"]
          enum: [deliverable, undeliverable, risky, unknown, null]
          description: Latest email verification result; null until the address has been checked. Details at GET /verifications.
        list_ids:
          type: array
          items: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    LeadInput:
      type: object
      required: [email]
      properties:
        email: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        company: { type: string }
        title: { type: string }
        phone: { type: string }
        linkedin_url: { type: string }
        website: { type: string }
        custom:
          type: object
          additionalProperties: { type: string }
        list_id: { type: string, format: uuid }
    LeadImportResult:
      type: object
      required: [created, updated, duplicates, suppressed, errors, list_id]
      properties:
        created: { type: integer }
        updated: { type: integer }
        duplicates: { type: integer, description: Rows merged into an earlier row with the same email. }
        suppressed: { type: integer, description: Imported leads that are on the suppression list. }
        errors:
          type: array
          items:
            type: object
            required: [line, error]
            properties:
              line: { type: integer }
              email: { type: string }
              error: { type: string }
        list_id: { type: [string, "null"], format: uuid }
    PeopleFilter:
      type: object
      description: >-
        A people search in shared terms. Each field lists alternatives (any
        may match); fields combine (all must match). GET /data-providers says
        which filters each provider understands.
      properties:
        titles: { type: array, maxItems: 50, items: { type: string }, example: [head of sales, vp sales] }
        seniorities:
          type: array
          maxItems: 50
          items: { type: string, enum: [owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern] }
        industries: { type: array, maxItems: 50, items: { type: string } }
        employee_ranges:
          type: array
          maxItems: 50
          items: { type: string, example: 11-50 }
          description: "Company sizes as min-max, or min+ for open-ended: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+."
        locations: { type: array, maxItems: 50, items: { type: string }, description: Where the person is. }
        company_locations: { type: array, maxItems: 50, items: { type: string }, description: Where the company is based. }
        company_domains: { type: array, maxItems: 50, items: { type: string }, example: [acme.com] }
        keywords: { type: array, maxItems: 50, items: { type: string } }
    LeadListInput:
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        description: { type: string, maxLength: 2000 }
        project_id: { type: [string, "null"], format: uuid, description: Null shares the list with the whole workspace. }
        search_integration_id: { type: [string, "null"], format: uuid, description: The connected provider that searches. Null makes a manual list. }
        filters: { $ref: "#/components/schemas/PeopleFilter" }
        enrich_integration_ids:
          type: array
          maxItems: 10
          items: { type: string, format: uuid }
          description: Providers that find missing emails, tried in order until one answers. Empty uses the search provider if it can.
        max_leads: { type: integer, minimum: 1, maximum: 50000, description: "Most new leads one run adds. Default 500." }
        credit_cap: { type: [number, "null"], minimum: 0, description: Most provider credits one run spends. Null for no cap. }
        verify: { type: boolean, description: "Verify new addresses and leave out ones the workspace's verification settings block. Default true." }
        verified_emails_only: { type: boolean, description: "Only add addresses the provider itself verified. Default false." }
        skip_contacted_days: { type: integer, minimum: 0, maximum: 3650, description: "Leave out people any sequence emailed in this many days. 0 turns it off. Default 90." }
        skip_in_sequences: { type: boolean, description: "Leave out people active in another sequence. Default true." }
        min_score: { type: integer, minimum: 0, maximum: 100, description: "Leave out people whose ICP fit is below this, before spending credits. Default 0." }
        refresh_every_days: { type: [integer, "null"], minimum: 1, maximum: 365, description: Refresh on a schedule. Null refreshes only when asked. }
    LeadList:
      type: object
      required: [id, name, description, project_id, kind, leads, search_integration_id, filters, enrich_integration_ids, max_leads,
        credit_cap, verify, verified_emails_only, skip_contacted_days, skip_in_sequences, min_score, refresh_every_days,
        next_refresh_at, last_run, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string }
        project_id: { type: [string, "null"], format: uuid }
        kind: { type: string, enum: [manual, search] }
        leads: { type: integer }
        search_integration_id: { type: [string, "null"], format: uuid }
        filters: { $ref: "#/components/schemas/PeopleFilter" }
        enrich_integration_ids: { type: array, items: { type: string, format: uuid } }
        max_leads: { type: integer }
        credit_cap: { type: [number, "null"] }
        verify: { type: boolean }
        verified_emails_only: { type: boolean }
        skip_contacted_days: { type: integer }
        skip_in_sequences: { type: boolean }
        min_score: { type: integer }
        refresh_every_days: { type: [integer, "null"] }
        next_refresh_at: { type: [string, "null"], format: date-time }
        last_run:
          oneOf:
            - $ref: "#/components/schemas/ListRun"
            - type: "null"
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    LeadListMember:
      allOf:
        - $ref: "#/components/schemas/Lead"
        - type: object
          required: [score, score_reasons, added_at, run_id]
          properties:
            score: { type: [integer, "null"], minimum: 0, maximum: 100, description: ICP fit. Null when the list and its project have no profile. }
            score_reasons: { type: array, items: { type: string }, example: [+ title, "- company size"] }
            added_at: { type: string, format: date-time }
            run_id: { type: [string, "null"], format: uuid }
    DataPerson:
      type: object
      description: A person as a provider described them.
      properties:
        provider_id: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        title: { type: string }
        seniority: { type: string }
        email: { type: string }
        email_status: { type: string, enum: [verified, guessed, ""] }
        phone: { type: string }
        linkedin_url: { type: string }
        city: { type: string }
        state: { type: string }
        country: { type: string }
        has_email: { type: boolean }
        company:
          type: object
          properties:
            name: { type: string }
            domain: { type: string }
            website: { type: string }
            industry: { type: string }
            employees: { type: integer }
            linkedin_url: { type: string }
            location: { type: string }
    ListRun:
      type: object
      required: [id, list_id, project_id, kind, status, params, found, total_matches, added, enriched, skipped, credits,
        credits_by, sample, stop_reason, error, requested_by, created_at, started_at, finished_at]
      properties:
        id: { type: string, format: uuid }
        list_id: { type: [string, "null"], format: uuid }
        project_id: { type: [string, "null"], format: uuid }
        kind: { type: string, enum: [preview, build, refresh, enrich] }
        status: { type: string, enum: [queued, running, done, failed, cancelled] }
        params: { type: object, additionalProperties: true, description: The settings the run used. }
        found: { type: integer, description: "People the search returned (or leads looked at, for enrich)." }
        total_matches: { type: [integer, "null"], description: Matches the provider reported. }
        added: { type: integer }
        enriched: { type: integer, description: People a provider filled in. }
        skipped:
          type: object
          description: "Why people were left out: already_in_list, low_score, no_email, unverified, suppressed, contacted, in_sequence, undeliverable."
          additionalProperties: { type: integer }
        credits: { type: number }
        credits_by: { type: object, additionalProperties: { type: number }, description: Credits by integration id. }
        sample: { type: array, items: { $ref: "#/components/schemas/DataPerson" }, description: Preview results. }
        stop_reason: { type: [string, "null"], description: "Why the run stopped early, e.g. the credit cap." }
        error: { type: [string, "null"] }
        requested_by: { type: [string, "null"], format: uuid }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, "null"], format: date-time }
        finished_at: { type: [string, "null"], format: date-time }
    DataProvider:
      type: object
      required: [connector, name, summary, website, docs_url, key_url, auth, fields, pricing, status, capabilities, filters, integrations]
      properties:
        connector: { type: string, example: apollo }
        name: { type: string }
        summary: { type: string }
        website: { type: string }
        docs_url: { type: string }
        key_url: { type: string, description: Where to find the API key. }
        auth: { type: string, enum: [api_key, oauth2, none] }
        fields:
          type: array
          description: What to paste to connect it with POST /integrations.
          items:
            type: object
            required: [name, label, secret, optional]
            properties:
              name: { type: string }
              label: { type: string }
              secret: { type: boolean }
              optional: { type: boolean }
              placeholder: { type: string }
              help: { type: string }
        pricing: { type: string }
        status: { type: string, enum: [available, planned] }
        capabilities: { type: array, items: { type: string } }
        filters: { type: array, items: { type: string }, description: Filters its search understands. Empty if it cannot search. }
        integrations:
          type: array
          items:
            type: object
            required: [id, name, status, account]
            properties:
              id: { type: string, format: uuid }
              name: { type: string }
              status: { type: string }
              account: { type: object, additionalProperties: true }
    SuppressionReason:
      type: string
      enum: [unsubscribed, bounced, complained, manual, do_not_contact]
    Suppression:
      type: object
      required: [id, kind, value, reason, note, source, created_at]
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, enum: [email, domain] }
        value: { type: string }
        reason: { $ref: "#/components/schemas/SuppressionReason" }
        note: { type: string }
        source: { type: string }
        created_at: { type: string, format: date-time }
    RecipientDecision:
      type: object
      required: [email, allowed, reasons]
      properties:
        email: { type: string }
        allowed: { type: boolean }
        reasons:
          type: array
          description: "Why it is blocked, e.g. invalid_email, suppressed_email:unsubscribed, suppressed_domain:do_not_contact."
          items: { type: string }
    AllocationMode:
      type: string
      enum: [suggest, automatic]
      description: "automatic: the allocator assigns mailboxes itself and explains each change. suggest: it proposes changes for a person to apply."
    ICP:
      type: object
      required: [titles, seniorities, industries, employee_ranges, locations, keywords]
      description: The ideal customer as filters list builders and data providers can run. Every key is always present.
      properties:
        titles: { type: array, items: { type: string }, example: [CFO, VP Finance] }
        seniorities: { type: array, items: { type: string }, example: [c_suite, vp] }
        industries: { type: array, items: { type: string }, example: [Financial Services] }
        employee_ranges: { type: array, items: { type: string }, example: ["51-200", "201-500"] }
        locations: { type: array, items: { type: string }, example: [Australia] }
        keywords: { type: array, items: { type: string }, example: [Series B] }
    ICPInput:
      type: object
      additionalProperties: false
      description: Any of the ICP keys, each up to 200 values of up to 200 characters. Duplicates and blanks are dropped.
      properties:
        titles: { type: array, items: { type: string } }
        seniorities: { type: array, items: { type: string } }
        industries: { type: array, items: { type: string } }
        employee_ranges: { type: array, items: { type: string } }
        locations: { type: array, items: { type: string } }
        keywords: { type: array, items: { type: string } }
    Project:
      type: object
      required: [id, name, status, offer, audience, market, brief, icp, brief_version, brief_updated_at, daily_send_limit, counts, sent_24h, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: "Fintech CFOs, AU" }
        status: { type: string, enum: [active, paused, archived], description: "paused: none of its sequences send. archived: paused and hidden from the default list." }
        offer: { type: string, description: What is sold. }
        audience: { type: string, description: Who buys it, in words. }
        market: { type: string, description: Where, and in which language. }
        brief: { type: string, description: "Everything else, in Markdown: messaging, proof, objections, words to avoid." }
        icp: { $ref: "#/components/schemas/ICP" }
        brief_version: { type: integer, description: Bumped on every change to offer, audience, market, brief or icp. }
        brief_updated_at: { type: string, format: date-time }
        daily_send_limit: { type: [integer, "null"], description: "Most cold sends from all of the project's sequences together in any 24 hours. Null is no project limit." }
        counts:
          type: object
          required: [domains, mailboxes, lead_lists, sequences, active_sequences]
          properties:
            domains: { type: integer }
            mailboxes: { type: integer }
            lead_lists: { type: integer }
            sequences: { type: integer, description: Not archived. }
            active_sequences: { type: integer }
        sent_24h: { type: integer, description: Cold sends from its sequences in the last 24 hours. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ProjectInput:
      type: object
      properties:
        name: { type: string, maxLength: 100 }
        status: { type: string, enum: [active, paused, archived] }
        offer: { type: string, maxLength: 4000 }
        audience: { type: string, maxLength: 4000 }
        market: { type: string, maxLength: 4000 }
        brief: { type: string, maxLength: 100000 }
        icp: { oneOf: [{ $ref: "#/components/schemas/ICPInput" }, { type: "null" }], description: Replaces the whole ICP. Null clears it. }
        daily_send_limit: { type: [integer, "null"], minimum: 1, maximum: 1000000 }
        brief_version: { type: integer, description: "Updates only: the version your edit is based on. A brief edit fails with 409 if the brief changed since." }
    ProjectBrief:
      type: object
      properties:
        project_id: { type: string, format: uuid }
        name: { type: string }
        status: { type: string, enum: [active, paused, archived] }
        version: { type: integer }
        updated_at: { type: string, format: date-time }
        offer: { type: string }
        audience: { type: string }
        market: { type: string }
        icp: { $ref: "#/components/schemas/ICP" }
        brief: { type: string }
        markdown: { type: string, description: Everything above as one Markdown document, ready to put in a prompt. }
    ProjectMembers:
      type: object
      properties:
        domains: { type: array, items: { type: string }, example: [acme-mail.com] }
        mailbox_ids: { type: array, items: { type: string, format: uuid } }
        lead_list_ids: { type: array, items: { type: string, format: uuid } }
        sequence_ids: { type: array, items: { type: string, format: uuid } }
        with_mailboxes: { type: boolean, description: "Whether a domain's mailboxes move with it. Default true." }
    ProjectMoved:
      type: object
      required: [domains, mailboxes, lead_lists, sequences]
      properties:
        domains: { type: integer }
        mailboxes: { type: integer }
        lead_lists: { type: integer }
        sequences: { type: integer }
    Team:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        allocation_mode: { type: [string, "null"], enum: [suggest, automatic, null], description: Null follows the workspace. }
        member_ids: { type: array, items: { type: string, format: uuid }, description: User ids. }
        senders: { type: integer }
        sequences: { type: integer }
        created_at: { type: string, format: date-time }
    TeamInput:
      type: object
      properties:
        name: { type: string, maxLength: 100 }
        allocation_mode: { type: [string, "null"], enum: [suggest, automatic, null] }
        member_ids: { type: array, items: { type: string, format: uuid } }
    Sender:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, description: "The name recipients see, e.g. Ana Smith." }
        kind: { type: string, enum: [person, persona, shared] }
        user_id: { type: [string, "null"], format: uuid, description: The workspace member this sender is, for kind person. }
        status: { type: string, enum: [active, retired] }
        team_ids: { type: array, items: { type: string, format: uuid }, description: Empty for a shared sender any team may use. }
        mailboxes: { type: integer }
        created_at: { type: string, format: date-time }
    SenderInput:
      type: object
      properties:
        name: { type: string, maxLength: 200 }
        kind: { type: string, enum: [person, persona, shared] }
        user_id: { type: [string, "null"], format: uuid }
        status: { type: string, enum: [active, retired] }
        team_ids: { type: array, items: { type: string, format: uuid } }
    AllocationBlocking:
      type: object
      description: Mailboxes that could not be used, grouped by why.
      properties:
        reason: { type: string, example: "not ready: still warming up" }
        count: { type: integer }
    PlannedChange:
      type: object
      properties:
        mailbox_id: { type: string, format: uuid }
        action: { type: string, enum: [assign, release, move, drain, finish_drain] }
        from_sequence_id: { type: string, format: uuid }
        to_sequence_id: { type: string, format: uuid }
        reason: { type: string, description: Plain-language explanation. }
        needs: { type: string, enum: [team_approval, owner_approval], description: Set when someone must approve it first. }
        approver_team_id: { type: string, format: uuid }
    AllocationCapacity:
      type: object
      properties:
        sequence_id: { type: string, format: uuid }
        needed_daily: { type: [integer, "null"], description: Sends a day the sequence should have capacity for. }
        active_daily: { type: integer }
        incoming_daily: { type: integer, description: Capacity from mailboxes still draining elsewhere. }
        blocking: { type: array, items: { $ref: "#/components/schemas/AllocationBlocking" } }
        note: { type: string }
    AllocationSuggestion:
      type: object
      properties:
        id: { type: string, format: uuid }
        sequence_id: { type: string, format: uuid }
        sequence_name: { type: string }
        mailbox_id: { type: string, format: uuid }
        mailbox_email: { type: string }
        action: { type: string, enum: [assign, release, drain] }
        from_sequence_id: { type: [string, "null"], format: uuid }
        needs: { type: string, enum: [review, team_approval, owner_approval] }
        approver_team_id: { type: [string, "null"], format: uuid }
        approver_team_name: { type: [string, "null"] }
        reason: { type: string }
        created_at: { type: string, format: date-time }
    AllocationChange:
      type: object
      properties:
        id: { type: string, format: uuid }
        mailbox_id: { type: string, format: uuid }
        mailbox_email: { type: string }
        action: { type: string, enum: [assign, release, move, drain, finish_drain] }
        from_sequence_id: { type: [string, "null"], format: uuid }
        from_sequence_name: { type: [string, "null"] }
        to_sequence_id: { type: [string, "null"], format: uuid }
        to_sequence_name: { type: [string, "null"] }
        source: { type: string, enum: [manual, automatic, approval, undo, external], description: "external is a change made in an outside sequencer and accepted on sync." }
        reason: { type: string }
        actor_name: { type: [string, "null"] }
        undone_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    AssignedMailbox:
      type: object
      properties:
        mailbox_id: { type: string, format: uuid }
        email: { type: string }
        sender_name: { type: [string, "null"] }
        state: { type: string, enum: [active, draining] }
        next_sequence_id: { type: [string, "null"], format: uuid }
        source: { type: string, enum: [manual, automatic, external] }
        reason: { type: string }
        daily_limit: { type: integer }
        in_flight: { type: integer, description: Leads mid-sequence from this mailbox. }
        lock_kind: { type: [string, "null"], enum: [sequence, team, manual_only, null] }
        assigned_at: { type: string, format: date-time }
        mailbox_status: { type: string }
        platform: { type: [string, "null"], description: The sequencer it sends through. Null when Outflare sends. }
        confirmed: { type: boolean, description: False until the platform shows the mailbox on the campaign. }
        drain_until: { type: [string, "null"], format: date-time, description: "When a mailbox leaving a platform campaign is detached: platforms don't say which leads each mailbox has follow-ups for, so it stays for the campaign's sequence length." }
    PlatformAccount:
      type: object
      properties:
        integration_id: { type: string, format: uuid }
        name: { type: string }
        connector: { type: string, example: emailbison }
        control: { type: string, enum: [full, read_only], description: "read_only accounts are watched but never changed: Apollo picks the mailbox for each contact itself." }
        synced_at: { type: [string, "null"], format: date-time }
        error: { type: [string, "null"], description: Why the last read failed. }
        stale: { type: boolean, description: "Not read successfully in 30 minutes. Its mailboxes count as busy until it is." }
        awaiting_review: { type: boolean, description: Changes for its campaigns are only suggested until the first plan is reviewed. }
        mailboxes: { type: integer }
        campaigns: { type: integer }
        unconfirmed: { type: integer, description: Attaches the platform does not show yet. }
        conflicts: { type: integer, description: Mailboxes on more than one campaign, or on a campaign they are not allocated to. }
    CampaignLink:
      type: object
      description: A platform campaign a sequence runs as. The platform owns its status, steps and leads; Outflare decides its mailboxes.
      properties:
        id: { type: string, format: uuid }
        integration_id: { type: string, format: uuid }
        platform: { type: string }
        connector: { type: string }
        external_id: { type: string }
        name: { type: string }
        status: { type: string }
        control: { type: string, enum: [full, read_only] }
        last_synced_at: { type: [string, "null"], format: date-time }
    MailboxAllocationResult:
      type: object
      properties:
        mailbox_id: { type: string, format: uuid }
        result: { type: string, enum: [assigned, moved, draining, released, unchanged, refused] }
        message: { type: string }
    SequenceSettings:
      type: object
      properties:
        name: { type: string, maxLength: 200 }
        project_id: { type: [string, "null"], format: uuid, description: "The project the sequence belongs to. Only that project's mailboxes and shared ones send for it. Null: shared." }
        pool_id: { type: [string, "null"], format: uuid, description: "Restrict allocation to mailboxes in this pool. Null allows any mailbox." }
        timezone: { type: string, example: Australia/Sydney, description: IANA time zone for the sending window. }
        send_days:
          type: array
          description: ISO weekdays, 1 (Monday) to 7 (Sunday).
          items: { type: integer, minimum: 1, maximum: 7 }
        send_start: { type: string, example: "09:00" }
        send_end: { type: string, example: "17:00" }
        daily_new_limit: { type: [integer, "null"], minimum: 1, description: Most leads started per day. Null is no cap. }
        stop_on_reply: { type: boolean }
        track_clicks: { type: boolean, description: "Rewrite links so clicks are counted. Off by default: rewritten links can hurt deliverability, so use it with a verified tracking domain." }
        unsubscribe_header: { type: boolean, description: "Add List-Unsubscribe and one-click headers. On by default; Gmail and Yahoo expect them from bulk senders." }
        team_id: { type: [string, "null"], format: uuid, description: "The team that owns it. Its mailboxes come from this team's senders first, then shared ones. An API token's default team is used when left out." }
        priority: { type: string, enum: [low, normal, high], description: "Higher priority may take mailboxes from lower, down to their min_daily. An API token cannot go above its max_priority." }
        target_daily: { type: [integer, "null"], minimum: 1, description: "Sends a day to allocate mailboxes for. Null uses daily_new_limit times the number of steps; with neither, no mailboxes are allocated automatically." }
        min_daily: { type: [integer, "null"], minimum: 1, description: Capacity a higher-priority sequence may never take below. }
        allocation_mode: { type: [string, "null"], enum: [suggest, automatic, null], description: Null follows the team, then the workspace. }
        preferred_sender_ids: { type: array, items: { type: string, format: uuid }, description: Senders to use first. Replaces the list when sent. }
    StepInput:
      type: object
      required: [body]
      properties:
        delay_days: { type: integer, minimum: 0, maximum: 365, description: Days after the previous step. }
        subject: { type: string, description: "Empty on a later step replies in the same thread." }
        body:
          type: string
          description: "Plain text. Variables: {{first_name}}, {{company|fallback}}, any custom field, {{sender_name}}, {{unsubscribe_url}}."
    Step:
      type: object
      required: [position, delay_days, subject, body]
      properties:
        position: { type: integer, description: 0 is the first step. }
        delay_days: { type: integer }
        subject: { type: string }
        body: { type: string }
    Sequence:
      type: object
      required: [id, name, status, pool_id, timezone, send_days, send_start, send_end, daily_new_limit, stop_on_reply, track_clicks, unsubscribe_header, team_id, priority, target_daily, min_daily, allocation_mode, preferred_sender_ids, started_by_agent, allocation, links, steps, stats, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        status: { type: string, enum: [draft, active, paused, archived] }
        project_id: { type: [string, "null"], format: uuid }
        pool_id: { type: [string, "null"], format: uuid }
        timezone: { type: string }
        send_days:
          type: array
          items: { type: integer }
        send_start: { type: string }
        send_end: { type: string }
        daily_new_limit: { type: [integer, "null"] }
        stop_on_reply: { type: boolean }
        track_clicks: { type: boolean }
        unsubscribe_header: { type: boolean }
        team_id: { type: [string, "null"], format: uuid }
        priority: { type: string, enum: [low, normal, high] }
        target_daily: { type: [integer, "null"] }
        min_daily: { type: [integer, "null"] }
        allocation_mode: { type: [string, "null"], enum: [suggest, automatic, null] }
        preferred_sender_ids: { type: array, items: { type: string, format: uuid } }
        started_by_agent: { type: boolean, description: Created with an API token. }
        mission_id: { type: [string, "null"], format: uuid, description: The agent mission it was started in. }
        allocation:
          type: object
          description: Mailbox capacity as of the last plan.
          required: [mailboxes, active_daily, needed_daily, note, planned_at]
          properties:
            mailboxes: { type: integer }
            active_daily: { type: integer }
            needed_daily: { type: [integer, "null"] }
            note: { type: string }
            planned_at: { type: [string, "null"], format: date-time }
        links:
          type: array
          description: "Platform campaigns this sequence runs as. When set, change its status, steps and leads in the platform: Outflare refuses them here."
          items: { $ref: "#/components/schemas/CampaignLink" }
        steps:
          type: array
          items: { $ref: "#/components/schemas/Step" }
        stats:
          type: object
          description: Enrollments by status, plus `sent` (messages delivered) and `clicked` (leads who clicked a tracked link, leaving out likely link scanners).
          additionalProperties: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    TrackingDomain:
      type: object
      required: [id, domain, status, error, target, last_checked_at, verified_at, created_at]
      properties:
        id: { type: string, format: uuid }
        domain: { type: string, example: links.acme.com }
        status: { type: string, enum: [pending, verified, failed], description: "pending until the CNAME is first seen; failed when a verified domain stops pointing here." }
        error: { type: string, description: What to fix, when not verified. }
        target: { type: string, description: The host the CNAME record must point to. }
        last_checked_at: { type: [string, "null"], format: date-time }
        verified_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    EnrollmentStatus:
      type: string
      enum: [active, completed, stopped, unsubscribed, bounced, replied, blocked, failed]
      description: "replied means the lead answered and the sequence stops on reply. unsubscribed also covers a reply asking to be removed."
    Enrollment:
      type: object
      required: [id, lead_id, email, name, company, status, status_reason, next_step, next_send_at, mailbox_email, created_at]
      properties:
        id: { type: string, format: uuid }
        lead_id: { type: string, format: uuid }
        email: { type: string }
        name: { type: string }
        company: { type: string }
        status: { $ref: "#/components/schemas/EnrollmentStatus" }
        status_reason:
          type: [string, "null"]
          description: "Why it is waiting or ended, e.g. outside_sending_window, mailbox_daily_limit, domain_paused, domain_daily_limit, server_paused, server_daily_limit, sequence_daily_new_limit, no_mailbox_allocated, sender_mailbox_gone (its mailbox was removed; resolve with orphaned-follow-ups), token_daily_budget, project_paused, project_daily_limit, missing_variable: first_name, suppressed_email:bounced."
        next_step: { type: integer }
        next_send_at: { type: [string, "null"], format: date-time }
        mailbox_email: { type: [string, "null"], description: The mailbox this lead's thread is sent from. }
        created_at: { type: string, format: date-time }
    Message:
      type: object
      required: [id, sequence_id, enrollment_id, step_position, lead_id, mailbox_id, from, to, subject, body, message_id, status, error, created_at, sent_at]
      properties:
        id: { type: string, format: uuid }
        sequence_id: { type: [string, "null"], format: uuid }
        enrollment_id: { type: [string, "null"], format: uuid }
        step_position: { type: [integer, "null"] }
        lead_id: { type: [string, "null"], format: uuid }
        mailbox_id: { type: [string, "null"], format: uuid }
        from: { type: string }
        to: { type: string }
        subject: { type: string }
        body: { type: string }
        message_id: { type: string, description: The Message-ID header. }
        status: { type: string, enum: [sending, sent, failed, blocked, unknown] }
        error: { type: [string, "null"], description: SMTP error, or the policy reason for a blocked message. }
        created_at: { type: string, format: date-time }
        sent_at: { type: [string, "null"], format: date-time }
    InboundMessage:
      type: object
      required: [id, mailbox_id, mailbox_email, from, from_name, to, subject, body, kind, opt_out, read, lead_id, lead_name, company, sequence_id, sequence_name, received_at, category, category_source, assigned_to, assignee_name, snoozed_until, done, labels]
      properties:
        id: { type: string, format: uuid }
        mailbox_id: { type: string, format: uuid }
        mailbox_email: { type: string }
        from: { type: string }
        from_name: { type: string }
        to: { type: string }
        subject: { type: string }
        body: { type: string, description: Plain text (HTML mail is converted). }
        kind:
          type: string
          enum: [reply, auto_reply, bounce, other]
          description: "reply: a lead answered. auto_reply: out of office and the like. bounce: a delivery failure report. other: not about a lead."
        opt_out: { type: boolean, description: The reply asked not to be contacted again; the address was suppressed. }
        read: { type: boolean }
        lead_id: { type: [string, "null"], format: uuid }
        lead_name: { type: [string, "null"] }
        company: { type: [string, "null"] }
        sequence_id: { type: [string, "null"], format: uuid }
        sequence_name: { type: [string, "null"] }
        received_at: { type: string, format: date-time }
        category:
          type: [string, "null"]
          enum: [interested, meeting_booked, question, referral, not_now, not_interested, wrong_person, out_of_office, unsubscribe, null]
          description: What the reply means. Set on arrival by built-in rules when they can tell, otherwise null until a person or agent sorts it.
        category_source:
          type: [string, "null"]
          enum: [rules, human, api_token, null]
          description: "Who set the category: the built-in rules, a person, or an API token (an agent or integration)."
        assigned_to: { type: [string, "null"], format: uuid, description: The member handling it. }
        assignee_name: { type: [string, "null"] }
        snoozed_until: { type: [string, "null"], format: date-time, description: Hidden from the open inbox until then. }
        done: { type: boolean, description: Handled. Hidden from the open inbox; a new reply from the lead arrives as a new open message. }
        labels: { type: array, items: { type: string } }
    ThreadEntry:
      type: object
      required: [id, direction, from, to, subject, body, status, at]
      properties:
        id: { type: string, format: uuid }
        direction: { type: string, enum: [in, out] }
        from: { type: string }
        to: { type: string }
        subject: { type: string }
        body: { type: string }
        status: { type: string, description: "For sent mail the message status; for received mail its kind." }
        at: { type: string, format: date-time }
    WorkspaceStats:
      type: object
      required: [mailboxes, domains, warmup_inbox_rate, sent_24h, sent_7d, replies_7d, bounces_7d, unread_replies, active_sequences, active_enrollments, leads]
      properties:
        mailboxes:
          type: object
          required: [total, active, error, paused, verifying, daily_capacity, warming]
          properties:
            total: { type: integer }
            active: { type: integer }
            error: { type: integer }
            paused: { type: integer }
            verifying: { type: integer }
            daily_capacity: { type: integer, description: "Most emails that can go out in 24 hours: active mailboxes' daily limits, held to each domain's cap, with paused domains at 0." }
            warming: { type: integer, description: Active mailboxes with warm-up on. }
        warmup_inbox_rate: { type: [number, "null"], description: "Share of the last 7 days' placed warm-up mail that reached the inbox." }
        domains:
          type: object
          required: [total, paused, need_attention]
          properties:
            total: { type: integer }
            paused: { type: integer }
            need_attention: { type: integer, description: Domains whose health is warning or critical. }
        sent_24h: { type: integer }
        sent_7d: { type: integer }
        replies_7d: { type: integer, description: Replies from people, not auto-replies or bounces. }
        bounces_7d: { type: integer }
        unread_replies: { type: integer, description: "Unread replies not yet marked done." }
        active_sequences: { type: integer }
        active_enrollments: { type: integer }
        leads: { type: integer }
    SupplierMailbox:
      type: object
      required: [external_id, email, name, provider, status, vendor_status, daily_limit, tags, connected_to, import_state, import_error, mailbox_id, mailbox_status, imported_at, updated_at]
      properties:
        external_id: { type: string, description: "The supplier's id for the mailbox." }
        email: { type: string }
        name: { type: string }
        provider: { type: string, enum: [google, microsoft, other] }
        status: { type: string, enum: [provisioning, active, cancelling, cancelled, failed], description: Status at the supplier. }
        vendor_status: { type: string, description: "The supplier's own status word." }
        daily_limit: { type: [integer, "null"], description: "The supplier's daily cap." }
        tags: { type: array, items: { type: string } }
        connected_to: { type: array, items: { type: string }, description: "Other sending platforms the supplier says this mailbox is connected to, e.g. emailbison." }
        import_state:
          type: string
          enum: [available, queued, waiting, app_password, imported, failed]
          description: |
            available: not imported. queued: importing now. waiting: imports
            when the supplier finishes setting it up. app_password: waiting
            for the supplier to make a Google app password. imported.
            failed: see import_error.
        import_error: { type: [string, "null"] }
        mailbox_id: { type: [string, "null"], format: uuid, description: The Outflare mailbox, once imported. }
        mailbox_status: { type: [string, "null"], description: "The Outflare mailbox's status: verifying, active, paused or error." }
        imported_at: { type: [string, "null"], format: date-time }
        updated_at: { type: string, format: date-time }
    SupplierSync:
      type: object
      required: [integration_id, auto_import, app_passwords, daily_limit, webhook, last_synced_at, last_sync_error]
      properties:
        integration_id: { type: string, format: uuid }
        auto_import: { type: boolean }
        app_passwords: { type: boolean }
        daily_limit: { type: [integer, "null"] }
        webhook: { type: boolean, description: "The supplier pushes changes; otherwise they arrive with the 15-minute sync." }
        last_synced_at: { type: [string, "null"], format: date-time }
        last_sync_error: { type: [string, "null"] }
    SupplierMailboxList:
      type: object
      required: [integration, sync, synced, counts, total, mailboxes]
      properties:
        integration: { type: object, additionalProperties: true, description: "The integration, as GET /v1/workspaces/{ws}/integrations/{id} returns it." }
        sync: { $ref: "#/components/schemas/SupplierSync" }
        synced: { type: boolean }
        counts:
          type: object
          required: [total, by_import_state, by_status, by_provider, connected_to]
          properties:
            total: { type: integer }
            by_import_state: { type: object, additionalProperties: { type: integer } }
            by_status: { type: object, additionalProperties: { type: integer } }
            by_provider: { type: object, additionalProperties: { type: integer } }
            connected_to: { type: object, additionalProperties: { type: integer } }
        total: { type: integer, description: How many match the filters. }
        mailboxes: { type: array, items: { $ref: "#/components/schemas/SupplierMailbox" } }
    SupplierImportResult:
      type: object
      required: [queued, waiting, not_found]
      properties:
        queued: { type: integer }
        waiting: { type: integer, description: Import once the supplier finishes setting them up. }
        not_found: { type: array, items: { type: string } }
    Status:
      type: object
      required: [status]
      properties:
        status:
          type: string
          example: ok
    Meta:
      type: object
      required: [name, version, api]
      properties:
        name:
          type: string
          example: Outflare
        version:
          type: string
          example: 0.1.0
        api:
          type: object
          required: [rest, openapi]
          properties:
            rest:
              type: string
              example: /v1
            openapi:
              type: string
              example: /v1/openapi.yaml
        capabilities:
          type: array
          items:
            type: string
        oauth_providers:
          type: array
          description: Providers whose consent screen is configured on this server (GOOGLE_OAUTH_CLIENT_ID, MICROSOFT_OAUTH_CLIENT_ID).
          items: { type: string, enum: [google, microsoft] }
        login_methods:
          type: array
          description: Ways to sign in on this server. google needs GOOGLE_OAUTH_CLIENT_ID with the sign-in callback registered.
          items: { type: string, enum: [password, google, passkey] }
        account_email:
          type: string
          example: postmark
          description: |
            How account emails (confirmation, password reset) are sent: `log`, `postmark`, `resend` or `cloudflare` in the
            open-source engine; a build with its own mail service may name another. `log` means no email service is set up
            (POSTMARK_SERVER_TOKEN, RESEND_API_KEY or CLOUDFLARE_EMAIL_API_TOKEN, with EMAIL_FROM), so links only appear in the server log.
        product_analytics:
          type: object
          description: |
            Present only when the operator set OUTFLARE_POSTHOG_KEY. The web app then sends product analytics to the
            operator's PostHog project through `api_host` on this server. Separate from the anonymous telemetry.
          required: [provider, key, api_host, ui_host]
          properties:
            provider: { type: string, enum: [posthog] }
            key: { type: string, description: "PostHog project key (public, it ships to the browser).", example: phc_abc123 }
            api_host: { type: string, example: /ingest }
            ui_host: { type: string, example: "https://us.posthog.com" }
    Passkey:
      type: object
      required: [id, name, created_at, last_used_at, synced]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }
        synced:
          type: boolean
          description: Backed up by a password manager (iCloud Keychain, Google Password Manager, 1Password), so it survives losing the device.
    EmailVerification:
      type: object
      required: [email, email_verified]
      properties:
        email: { type: string }
        email_verified: { type: boolean }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: unavailable
            message:
              type: string
            request_id:
              type: string
    LifecycleStage:
      type: string
      enum: [warming, ready, resting, retired]
      description: |
        Where a mailbox is in its working life. Only ready mailboxes send cold
        email. warming: warming up. resting: no cold email for a while,
        warm-up continues. retired: out of use for good.
    InfraPersona:
      type: object
      required: [first_name]
      properties:
        first_name: { type: string, maxLength: 40 }
        last_name: { type: string, maxLength: 40 }
    InfraWarning:
      type: object
      required: [code, message, blocking]
      properties:
        code:
          type: string
          description: no_personas, not_enough_domains, too_few_personas, ready_by_too_soon, short_warmup, many_per_domain, quote_pending, quote_failed, domains_unavailable
        message: { type: string }
        blocking: { type: boolean, description: The plan cannot be approved until this is fixed. }
    InfraTimeline:
      type: object
      required: [setup_by, ready_on]
      properties:
        setup_by: { type: string, format: date-time, description: Buying and DNS done by this date. }
        ready_on: { type: string, format: date-time, description: The earliest every mailbox can be ready, if warm-up goes well. }
    InfraPlanInput:
      type: object
      properties:
        name: { type: string, maxLength: 200 }
        project_id: { type: [string, "null"], format: uuid }
        supplier:
          type: string
          description: "manual (you buy; the plan waits for you), or a supplier connector such as cheapinboxes. See `suppliers` on GET /infrastructure."
        supplier_integration_id: { type: [string, "null"], format: uuid, description: The connected supplier account to buy with. }
        provider: { type: string, enum: [google, microsoft] }
        domain_count: { type: integer, minimum: 1, maximum: 200 }
        mailboxes_per_domain: { type: integer, minimum: 1, maximum: 10 }
        brand: { type: string, maxLength: 60, description: Domain names are made from it, e.g. getacme.com, acmehq.com. }
        personas: { type: array, items: { $ref: "#/components/schemas/InfraPersona" }, maxItems: 20 }
        domains: { type: array, items: { type: string }, description: Domain names you chose. The rest are suggested from brand. }
        ready_by: { type: [string, "null"], format: date }
        warmup_days: { type: integer, minimum: 7, maximum: 60 }
        mailbox_daily_limit: { type: integer, minimum: 1, maximum: 500, description: Cold emails a day per mailbox once ready. }
        rest_below: { type: integer, minimum: 0, maximum: 100, description: A ready mailbox rests when its warm-up inbox placement falls below this percent. }
        retire_after_days: { type: [integer, "null"], minimum: 3, maximum: 120, description: Retire a mailbox that rested this long without recovering. Null never does. }
        notes: { type: string, maxLength: 4000 }
    InfraPlanDraft:
      type: object
      required: [domains, timeline, daily_capacity, warnings]
      properties:
        domains:
          type: array
          items:
            type: object
            required: [name, mailboxes]
            properties:
              name: { type: string }
              mailboxes:
                type: array
                items:
                  type: object
                  required: [email, first_name, last_name]
                  properties:
                    email: { type: string }
                    first_name: { type: string }
                    last_name: { type: string }
        timeline: { $ref: "#/components/schemas/InfraTimeline" }
        daily_capacity: { type: integer }
        warnings: { type: array, items: { $ref: "#/components/schemas/InfraWarning" } }
    InfraPlanItem:
      type: object
      required: [id, kind, name, domain, owned, first_name, last_name, stage, detail, available, price_cents, mailbox_id, lifecycle_stage, stage_changed_at]
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, enum: [domain, mailbox] }
        name: { type: string, description: The domain, or the mailbox's address. }
        domain: { type: string }
        owned: { type: boolean, description: The workspace already had this domain. }
        first_name: { type: string }
        last_name: { type: string }
        stage:
          type: string
          enum: [planned, buying, dns, connecting, done, failed, cancelled]
          description: "The build: planned, buying (ordered, or waiting for you to buy), dns (waiting for SPF, DKIM and DMARC), connecting (waiting for the mailbox), done."
        detail: { type: string, description: What is left to do, in plain words. }
        available: { type: [boolean, "null"], description: The supplier's answer to the quote. }
        price_cents: { type: [integer, "null"], format: int64 }
        mailbox_id: { type: [string, "null"], format: uuid }
        lifecycle_stage:
          oneOf: [{ $ref: "#/components/schemas/LifecycleStage" }, { type: "null" }]
        stage_changed_at: { type: string, format: date-time }
    InfraPlan:
      type: object
      required: [id, status, name, project_id, supplier, supplier_integration_id, ready_by, retire_after_days, order_ref, created_by, approved_by, approved_at, completed_at, provider, domain_count, mailboxes_per_domain, brand, personas, warmup_days,
                 mailbox_daily_limit, rest_below, notes, quote, timeline, daily_capacity, warnings, counts, created_at, updated_at]
      properties:
        name: { type: string, maxLength: 200 }
        project_id: { type: [string, "null"], format: uuid }
        supplier:
          type: string
          description: "manual (you buy; the plan waits for you), or a supplier connector such as cheapinboxes. See `suppliers` on GET /infrastructure."
        supplier_integration_id: { type: [string, "null"], format: uuid, description: The connected supplier account to buy with. }
        provider: { type: string, enum: [google, microsoft] }
        domain_count: { type: integer, minimum: 1, maximum: 200 }
        mailboxes_per_domain: { type: integer, minimum: 1, maximum: 10 }
        brand: { type: string, maxLength: 60, description: Domain names are made from it, e.g. getacme.com, acmehq.com. }
        personas: { type: array, items: { $ref: "#/components/schemas/InfraPersona" }, maxItems: 20 }
        ready_by: { type: [string, "null"], format: date }
        warmup_days: { type: integer, minimum: 7, maximum: 60 }
        mailbox_daily_limit: { type: integer, minimum: 1, maximum: 500, description: Cold emails a day per mailbox once ready. }
        rest_below: { type: integer, minimum: 0, maximum: 100, description: A ready mailbox rests when its warm-up inbox placement falls below this percent. }
        retire_after_days: { type: [integer, "null"], minimum: 3, maximum: 120, description: Retire a mailbox that rested this long without recovering. Null never does. }
        notes: { type: string, maxLength: 4000 }
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [draft, building, warming, ready, cancelled]
          description: "draft: nothing bought. building: buying, DNS and connecting. warming: every mailbox warming up. ready: every mailbox can send."
        quote:
          type: object
          required: [status]
          description: The supplier's price. Manual plans have status none.
          properties:
            status: { type: string, enum: [none, pending, ready, failed] }
            currency: { type: string }
            once_cents: { type: [integer, "null"], format: int64 }
            monthly_cents: { type: [integer, "null"], format: int64 }
            error: { type: string }
        order_ref: { type: [string, "null"] }
        timeline: { $ref: "#/components/schemas/InfraTimeline" }
        daily_capacity: { type: integer, description: Cold emails a day across the plan's mailboxes once ready. }
        warnings: { type: array, items: { $ref: "#/components/schemas/InfraWarning" } }
        counts:
          type: object
          required: [domains, mailboxes]
          properties:
            domains: { type: object, additionalProperties: { type: integer } }
            mailboxes: { type: object, additionalProperties: { type: integer }, description: Build stage until connected, then lifecycle stage. }
        items: { type: array, items: { $ref: "#/components/schemas/InfraPlanItem" }, description: Only on GET of one plan. }
        created_by: { type: [string, "null"], format: uuid }
        approved_by: { type: [string, "null"], format: uuid }
        approved_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    InfrastructureOverview:
      type: object
      required: [mailboxes, not_ready, plans, suppliers]
      properties:
        mailboxes:
          type: object
          required: [warming, ready, resting, retired]
          properties:
            warming: { type: integer }
            ready: { type: integer }
            resting: { type: integer }
            retired: { type: integer }
        not_ready:
          type: array
          items:
            type: object
            required: [id, email, domain, stage, reason, until, changed_at, auto_rest, plan_id, placed_7d, inbox_rate]
            properties:
              id: { type: string, format: uuid }
              email: { type: string }
              domain: { type: string }
              stage: { $ref: "#/components/schemas/LifecycleStage" }
              reason: { type: string }
              until: { type: [string, "null"], format: date-time }
              changed_at: { type: string, format: date-time }
              auto_rest: { type: boolean }
              plan_id: { type: [string, "null"], format: uuid }
              placed_7d: { type: integer }
              inbox_rate: { type: [number, "null"] }
        plans: { type: array, items: { $ref: "#/components/schemas/InfraPlan" } }
        suppliers: { type: array, items: { type: string } }
