openapi: 3.1.0
info:
  title: HeadlessFleet Cloud Browser API
  version: 0.1.0
  summary: Isolated Chromium sessions, persistent profiles, proxy targeting, and browser jobs
  description: |
    The customer and project-administration API for HeadlessFleet Cloud Browser.

    Authenticate with a project API key in `Authorization: Bearer <key>`. API
    keys are scoped; every operation documents its required scope in
    `x-required-scopes` or `x-required-any-scope`. A corresponding `*:write`
    scope also satisfies that resource's `*:read` scope.

    After successful API-key or dashboard-session authentication, HTTP
    responses include standard `RateLimit-*` headers and legacy
    `X-RateLimit-*` equivalents. Times are RFC 3339 UTC timestamps,
    byte quantities are integers, and monetary quantities ending in `_micros`
    are millionths of the stated currency unit.

    A running session is stopped once `idle_timeout_seconds` (at most 600)
    pass without activity or a heartbeat. CDP and live-view traffic through
    the URLs a session returns counts as activity, so a client that keeps
    driving its browser does not need `POST /v1/sessions/{sessionId}/heartbeat`;
    WebSocket ping and pong frames do not count. A client that waits longer
    without traffic, for example on a slow model call, sends heartbeats.

    Every authenticated request made with an API key of a suspended project is
    refused with `403 project_suspended`, whatever the key's scopes. Signed-in
    dashboard users keep read, payment, and wind-down access.

    CDP connection URLs and API-key secrets are credentials. Never log, persist,
    or relay them. The internal worker, snapshot, metrics, and OIDC routes are
    intentionally excluded from this public contract and documented separately
    in `docs/INTERNAL_API_REFERENCE.md`.
  contact:
    name: HeadlessFleet support
    url: https://app.headlessfleet.com
servers:
  - url: https://api.headlessfleet.com
    description: Beta API
security:
  - bearerApiKey: []
tags:
  - name: Operations
    description: Unauthenticated process health
  - name: Project
    description: Authenticated project and entitlement metadata
  - name: Profiles
    description: Persistent browser state and sticky proxy affinity
  - name: Proxies
    description: Customer-safe location discovery and administrative inventory
  - name: Sessions
    description: Browser admission, lifecycle, and metering checkpoints
  - name: Connections
    description: CDP and live-view WebSocket gateways, authenticated by connection tokens that stay valid until the session ends
  - name: Jobs
    description: High-level queued browser automation and artifacts
  - name: API keys
    description: Scoped project credentials and rotation
  - name: Usage
    description: Metered project usage and statements
  - name: Audit
    description: Organization audit trail
  - name: Billing
    description: Subscription state and prepaid credit
  - name: Administration
    description: Restricted worker and proxy operations
  - name: Webhooks
    description: Provider callbacks and signed outbound events

paths:
  /healthz:
    get:
      tags: [Operations]
      operationId: getHealth
      summary: Check control-plane process health
      description: Liveness only. This does not verify durable dependencies.
      security: []
      responses:
        "200":
          description: Process is running
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
              example: { status: ok }

  /v1/project:
    get:
      tags: [Project]
      operationId: getProject
      summary: Get the authenticated project and plan entitlements
      x-required-scopes: []
      responses:
        "200":
          description: Authenticated project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/me:
    get:
      tags: [Project]
      operationId: getAuthenticatedPrincipal
      summary: Get the authenticated identity, role, and effective scopes
      description: Returns the current API-key or organization-member principal. API-key secrets are never returned.
      x-required-scopes: []
      responses:
        "200":
          description: Authenticated principal
          content:
            application/json:
              schema:
                type: object
                required: [actor_type, actor_id, scopes, project]
                properties:
                  actor_type: { type: string, enum: [api_key, user] }
                  actor_id: { type: string }
                  api_key_id: { type: string }
                  session_id: { type: string }
                  role: { type: string }
                  email: { type: string, format: email }
                  name: { type: string, description: Display name of a dashboard user. }
                  email_verified: { type: boolean, description: True once a dashboard user has confirmed their email address. Always false for API keys. }
                  organization_name: { type: string }
                  operator:
                    type: boolean
                    description: True when the principal belongs to a platform-operator organization and therefore keeps `admin:*` and `labs:*` scopes.
                  scopes:
                    type: array
                    items: { type: string }
                  project: { $ref: "#/components/schemas/Project" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/profiles:
    get:
      tags: [Profiles]
      operationId: listProfiles
      summary: List persistent profiles
      x-required-scopes: [profiles:read]
      responses:
        "200":
          description: Profiles in the authenticated project
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProfileList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      tags: [Profiles]
      operationId: createProfile
      summary: Create a persistent browser profile
      description: |
        Creates storage for cookies and browser state. To preserve an existing
        interactive login, provide `session_id` for a running ephemeral session
        and omit `proxy`; the session is atomically converted to a profile writer
        and its active endpoint/location/carrier become sticky. The normal
        session DELETE then commits its encrypted browser state. Otherwise omit
        `session_id` and optionally provide `proxy` to create an empty profile.
        Only one writable session may use a profile.
      x-required-scopes: [profiles:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateProfile" }
            examples:
              dallasShared:
                summary: Sticky shared-pool profile in Dallas
                value:
                  name: Dallas monitoring
                  proxy: { country: US, region: TX, city: Dallas, pool: shared, mode: sticky }
              saveRunningManualSession:
                summary: Save a running manual session as a profile
                value:
                  name: Checkout QA
                  session_id: ses_example
      responses:
        "201":
          description: Profile created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Profile" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/profiles/{profileId}:
    parameters:
      - $ref: "#/components/parameters/ProfileId"
    delete:
      tags: [Profiles]
      operationId: deleteProfile
      summary: Permanently delete an unused profile
      description: Active profiles cannot be deleted. Encrypted profile objects are scheduled for retention-policy purge.
      x-required-scopes: [profiles:write]
      responses:
        "204": { description: Profile accepted for deletion }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/plans:
    get:
      tags: [Project]
      operationId: listPlans
      summary: List the public plan catalog
      description: Returns every active plan with its entitlements, usage rates, and monthly fee. No authentication is required; provider identifiers are never included. `checkout_available` is true only when a subscription checkout can be created for the plan. It does not reflect the caller's organization; while the organization already has a subscription, checkout answers `409 subscription_exists`, and the plan changes on that subscription instead (see `POST /v1/billing/portal`).
      security: []
      responses:
        "200":
          description: Active plans ordered for display
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Plan" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/proxy-locations:
    get:
      tags: [Proxies]
      operationId: listProxyLocations
      summary: List city-granular proxy locations available for browser launch
      description: |
        Returns a customer-safe, capacity-aware snapshot grouped by exact
        country/state/city/timezone and pool. Aggregate capacity buckets explain
        whether slots are free, active, sticky-reserved, rotating, unhealthy,
        or usage-blocked without exposing endpoint or tenant identity.
        Availability is not a reservation; queued admission handles races after
        the snapshot.
      x-required-any-scope: [jobs:write, profiles:write, sessions:write]
      parameters:
        - name: pool
          in: query
          required: false
          description: Return only locations in the selected dedicated or shared pool.
          schema: { $ref: "#/components/schemas/ProxyPool" }
      responses:
        "200":
          description: Proxy location catalog
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProxyLocationList" }
              examples:
                cities:
                  value:
                    data:
                      - { country: US, region: CO, city: Denver, timezone: America/Denver, pool: dedicated, modes: [random, sticky], operations: [session_create, profile_create, retarget, retry, relocate], carriers: [], capacity: 3, provisioned_capacity: 5, available_capacity: 2, active_capacity: 1, sticky_reserved_capacity: 2, rotation_blocked_capacity: 0, health_blocked_capacity: 0, usage_blocked_capacity: 0, unavailable_reasons: [active_leases, sticky_reserved], available: true }
                      - { country: US, region: TX, city: Dallas, timezone: America/Chicago, pool: shared, modes: [random, sticky], operations: [session_create, profile_create, retarget, retry], carriers: [], capacity: 10, provisioned_capacity: 10, available_capacity: 10, active_capacity: 0, sticky_reserved_capacity: 0, rotation_blocked_capacity: 0, health_blocked_capacity: 0, usage_blocked_capacity: 0, unavailable_reasons: [], available: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/proxy-relocation-locations:
    get:
      tags: [Proxies]
      operationId: listProxyRelocationLocations
      summary: List verified targets accepted by controlled proxy relocation
      description: |
        Returns a customer-safe country/state/city/timezone catalog derived
        only from verified dedicated proxy inventory. It does not expose
        endpoint IDs, provider accounts, credentials, capacity, occupancy, or
        public IPs. Relocation requests must select an exact catalog record.
      x-required-any-scope: [jobs:write, profiles:write, sessions:write, labs:proxy]
      responses:
        "200":
          description: Verified relocation target catalog
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProxyRelocationLocationList" }
              example:
                data:
                  - { country: US, region: AZ, city: Phoenix, timezone: America/Phoenix }
                  - { country: US, region: TX, city: Dallas, timezone: America/Chicago }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/session-batches:
    post:
      tags: [Sessions]
      operationId: createSessionBatch
      summary: Durably enqueue multiple browser sessions
      description: |
        Enqueues between 1 and 125 sessions in input order. Authentication is
        performed once, but concurrency, queue, profile, billing, and proxy
        admission are enforced independently for every item. The response
        contains an HTTP-equivalent status and either a session or safe error
        for each item. One batch cannot bypass project browser concurrency.
        Each item that would take the project past twice
        `max_concurrent_sessions` pending sessions reports `http_status` 429
        with `error.code` `too_many_queued_sessions`; the items before it are
        still admitted.
      x-required-scopes: [sessions:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateSessionBatch" }
      responses:
        "207":
          description: Ordered per-item admission results
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionBatchResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions:
    get:
      tags: [Sessions]
      operationId: listSessions
      summary: List project browser sessions
      description: Returns at most the 1,000 most recent project sessions. Counts describe the complete project inventory even when data is status-filtered.
      x-required-scopes: [sessions:read]
      parameters:
        - name: status
          in: query
          required: false
          description: One or more repeated or comma-separated session statuses to include in data.
          schema:
            type: array
            items: { type: string, enum: [pending, starting, running, stopping, completed, timed_out, failed, canceled] }
          style: form
          explode: true
        - name: ids
          in: query
          required: false
          description: |
            Between 1 and 125 repeated or comma-separated session IDs. When
            supplied, data is fetched with one project-scoped database query
            and returned in requested order. `unmatched_ids` contains IDs that
            are not visible to the project or do not match the status filter.
          schema:
            type: array
            maxItems: 125
            uniqueItems: true
            items: { type: string, minLength: 1, maxLength: 128 }
          style: form
          explode: true
      responses:
        "200":
          description: Browser sessions
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      tags: [Sessions]
      operationId: createSession
      summary: Create or enqueue an isolated browser session
      description: |
        By default the request waits for the short synchronous start budget.
        It returns `201` if the browser becomes ready in that budget, otherwise
        `202` with a durable pending/starting session and a `Location` header.
        Browser time and a proxy lease begin only after dispatch; queued time is
        not browser usage. Set `wait=false` to return immediately after durable
        enqueue. A pending ephemeral response also advertises proxy-location and
        retarget links. Pending sessions can be canceled with `DELETE`. While
        the project already has twice `max_concurrent_sessions` sessions
        pending, a create returns `429` with `error.code`
        `too_many_queued_sessions` and nothing is queued.
        `lab_overrides` is a restricted non-production field and additionally
        requires the `labs:browser` scope. When omitted, production identity
        defaults are unchanged.
      x-required-scopes: [sessions:write]
      parameters:
        - name: wait
          in: query
          required: false
          description: Set to false to return immediately after enqueue.
          schema: { type: boolean, default: true }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateSession" }
            examples:
              ephemeralDallas:
                summary: Random shared-pool browser in Dallas
                value:
                  timeout_seconds: 600
                  idle_timeout_seconds: 120
                  queue_timeout_seconds: 120
                  display_mode: headless
                  automation: { mode: standard_cdp }
                  viewport: { width: 1440, height: 900 }
                  proxy: { country: US, region: TX, city: Dallas, pool: shared, mode: random }
              savedProfile:
                summary: Launch an existing sticky profile
                value:
                  profile_id: prf_example
                  timeout_seconds: 600
                  idle_timeout_seconds: 120
                  display_mode: headful
                  automation: { mode: standard_cdp }
      responses:
        "201":
          description: Browser is running and contains its connection URLs, which are credentials valid until the session ends
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "202":
          description: Session was durably accepted and remains pending or starting
          headers:
            Location:
              description: Relative URL to poll for status
              schema: { type: string, example: /v1/sessions/ses_example }
            Link:
              description: A pending ephemeral session advertises absolute headlessfleet.com proxy-location-options and proxy-retarget extension relation URIs.
              schema: { type: string }
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions/batch-stop:
    post:
      tags: [Sessions]
      operationId: stopSessionBatch
      summary: Cancel or stop multiple browser sessions
      description: |
        Stops between 1 and 125 distinct project sessions with at most ten
        teardown operations executing concurrently. Input order is preserved
        and every item receives an independent HTTP-equivalent result.
        Terminal sessions remain idempotent.
      x-required-scopes: [sessions:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StopSessionBatch" }
      responses:
        "207":
          description: Ordered per-item stop results
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionBatchResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions/{sessionId}:
    parameters:
      - $ref: "#/components/parameters/SessionId"
    get:
      tags: [Sessions]
      operationId: getSession
      summary: Get a browser session
      description: A failed ephemeral session advertises its retry operation with the absolute `https://api.headlessfleet.com/rels/session-retry` link relation.
      x-required-scopes: [sessions:read]
      responses:
        "200":
          description: Current durable session state
          headers:
            Link:
              description: Retry operation for an eligible failed ephemeral session; absent for ineligible sessions
              schema: { type: string, example: '</v1/sessions/ses_failed/retry>; rel="https://api.headlessfleet.com/rels/session-retry"' }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    delete:
      tags: [Sessions]
      operationId: stopSession
      summary: Cancel or stop a browser session
      description: |
        Cancels a pending session, or tears down a running browser and commits
        its profile snapshot when applicable. Terminal sessions are idempotently
        returned. A session already in its starting transition may return `409`.
      x-required-scopes: [sessions:write]
      responses:
        "200":
          description: Terminal session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions/{sessionId}/heartbeat:
    parameters:
      - $ref: "#/components/parameters/SessionId"
    post:
      tags: [Sessions]
      operationId: heartbeatSession
      summary: Refresh the idle deadline and checkpoint usage
      description: |
        Immediately moves a running session's `idle_deadline` to now plus
        `idle_timeout_seconds`, unless it is already later, and checkpoints
        usage. Does not extend the hard session expiry (`expires_at`).

        Heartbeats are optional while the browser is driven through its
        returned URLs: every CDP data message relayed through `cdp_url` in
        either direction, and live-view frames and forwarded input, count as
        activity and refresh the same deadline. Activity is saved with a delay
        of up to about 10 seconds; a heartbeat applies at once. Use heartbeats
        to keep a session that has no traffic for longer than its
        `idle_timeout_seconds` (at most 600), for example while a slow model
        call runs. WebSocket ping and pong frames are not activity.

        Returns `409 session_not_running` unless the session is running
        (including one a concurrent stop has claimed), and
        `403 project_suspended` for a suspended project. Credit exhaustion can
        stop the session during this checkpoint.
      x-required-scopes: [sessions:write]
      responses:
        "200":
          description: Updated session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions/{sessionId}/proxy-failures:
    parameters:
      - $ref: "#/components/parameters/SessionId"
    post:
      tags: [Sessions]
      operationId: reportSessionProxyFailure
      summary: Report a running session's proxy tunnel failure
      description: |
        A session owner may report an observed tunnel failure on an ephemeral
        random-proxy session. The control plane asks our mobile proxy network
        to relocate the assigned proxy using its provider-side random target,
        verifies and records the resulting authoritative location, timezone,
        and carrier, and does not place the endpoint into a health cooldown. Provider
        identity and credentials remain private. This does not migrate or
        restart the already-running browser; stop it and retry the failed
        session to receive another random allocation.
      x-required-scopes: [sessions:write]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProxyFailureReport" }
      responses:
        "204": { description: Failure recorded and provider-side random relocation requested }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions/{sessionId}/proxy/rotate:
    parameters:
      - $ref: "#/components/parameters/SessionId"
    post:
      tags: [Sessions]
      operationId: rotateSessionProxy
      summary: Rotate or replace the proxy of a running random session
      description: |
        Changes the proxy of a running ephemeral session whose proxy mode is
        `random` without deleting the browser. A dedicated session rotates the
        carrier IP of its proxy's modem, connecting an idle proxy first; if
        the proxy cannot connect, this returns
        `409 proxy_rotation_requires_connection`. A rotation that is accepted
        but whose new IP is not confirmed within the verification window
        returns `202` with status `pending`. A shared session
        reserves a different endpoint with the same location and carrier,
        atomically retargets only that VM's stable metering relay, closes its
        old tunnels, commits the new lease, and releases the old lease. Other
        users of the previous shared endpoint are unaffected. A shared
        replacement returns action `replace`; a dedicated rotation returns
        action `rotate`. Provider identity, credentials, and public IP remain
        private.
      x-required-scopes: [sessions:write]
      responses:
        "200":
          description: Dedicated rotation or shared replacement completed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProxyActionResult" }
        "202":
          description: Rotation was accepted and is still completing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProxyActionResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions/{sessionId}/proxy/relocate:
    parameters:
      - $ref: "#/components/parameters/SessionId"
    post:
      tags: [Sessions]
      operationId: relocateSessionProxy
      summary: Relocate the dedicated proxy lease of a running lab session
      description: Phase one accepts exact US locations already advertised for the session pool, optionally with a carrier. Inventory changes only after provider list/detail agreement. A location without free capacity returns `409 proxy_location_at_capacity`; choose another location.
      x-required-scopes: [labs:proxy]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProxyRelocateInput" }
      responses:
        "200":
          description: Relocation completed and authoritative metadata was persisted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProxyActionResult" }
        "202":
          description: Provider accepted the relocation but list/detail confirmation is pending
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProxyActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions/{sessionId}/proxy/retarget:
    parameters:
      - $ref: "#/components/parameters/SessionId"
    post:
      tags: [Sessions]
      operationId: retargetPendingSessionProxy
      summary: Change the proxy requirement of an ephemeral pending session
      description: |
        Retargets a lease-free ephemeral session while it is pending and wakes
        dispatch immediately. The original queue deadline and browser-start
        attempt count are preserved. Saved profiles are rejected because their
        sticky location and proxy identity are immutable. Select an advertised
        country, region, city, and pool from `GET /v1/proxy-locations`; mode must
        be `random` or omitted.
      x-required-scopes: [sessions:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RetargetProxyRequest" }
            example: { country: US, region: AZ, city: Phoenix, pool: dedicated, mode: random }
      responses:
        "202":
          description: Session requirement changed and dispatch was reawakened
          headers:
            Location:
              schema: { type: string, example: /v1/sessions/ses_example }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions/{sessionId}/retry:
    parameters:
      - $ref: "#/components/parameters/SessionId"
    post:
      tags: [Sessions]
      operationId: retryFailedSession
      summary: Create a replacement for a failed ephemeral session
      description: |
        Creates a new queued session from a failed ephemeral session. The failed
        predecessor remains terminal and unchanged. When `proxy` is omitted, the
        replacement inherits the predecessor's resolved country, region, city,
        carrier, and pool and requests another random allocation. When `proxy`
        is supplied, it is a complete replacement selected from
        `GET /v1/proxy-locations`; country and pool are required, and mode must
        be `random` or omitted. Partial proxy overrides are rejected. Sessions
        backed by a saved profile and sessions in any state other than `failed`
        are rejected. The replacement is admitted like a new session, so it can
        be refused with `429 too_many_queued_sessions`. A failed ephemeral
        session representation advertises this operation with the absolute
        `https://api.headlessfleet.com/rels/session-retry` link relation.
      x-required-scopes: [sessions:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RetrySessionRequest" }
            example:
              proxy: { country: US, region: AZ, city: Phoenix, pool: dedicated, mode: random }
              queue_timeout_seconds: 120
      responses:
        "202":
          description: Replacement session created and queued
          headers:
            Location:
              description: Resource path of the replacement session
              schema: { type: string, example: /v1/sessions/ses_replacement }
            Link:
              description: Link to the failed predecessor with registered relation `predecessor-version`
              schema: { type: string, example: '</v1/sessions/ses_failed>; rel="predecessor-version"' }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/sessions/{sessionId}/extend:
    parameters:
      - $ref: "#/components/parameters/SessionId"
    post:
      tags: [Sessions]
      operationId: extendSession
      summary: Reset expiry from now within the plan lifetime limit
      description: |
        Sets `expires_at` to now plus `timeout_seconds` (at least 30) without
        exceeding the plan's maximum total session lifetime. This is the only
        operation that moves `expires_at`; activity and heartbeats move only
        `idle_deadline`. If the idle deadline has already passed, it is also
        reset to now plus `idle_timeout_seconds`.
      x-required-scopes: [sessions:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExtendSession" }
            example: { timeout_seconds: 600 }
      responses:
        "200":
          description: Extended running session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/connect/cdp/{sessionId}/{path}:
    parameters:
      - $ref: "#/components/parameters/SessionId"
      - name: path
        in: path
        required: true
        description: Greedy CDP path supplied in the returned `cdp_url`; clients must not construct it manually.
        schema: { type: string }
      - name: token
        in: query
        required: true
        description: Connection token embedded in the returned `cdp_url`; valid until the session ends.
        schema: { type: string, writeOnly: true }
    get:
      tags: [Connections]
      operationId: connectSessionCDP
      summary: Upgrade to the session CDP WebSocket
      servers:
        - url: wss://connect.headlessfleet.com
          description: Beta CDP WebSocket gateway
      description: |
        Connect using the complete `cdp_url` returned by a running session.
        This route uses its query token instead of the API-key bearer header.
        The token stays valid until the session ends, even if the API key that
        created the session is revoked; stopping the session revokes it. It
        accepts WebSocket clients without an Origin header, or a same-origin
        browser client. Compression is disabled.

        Every data message relayed in either direction is session activity and
        keeps the browser from reaching its idle deadline. Opening the
        connection, an open connection without messages, and WebSocket ping,
        pong, and close frames are not activity. Disconnecting does not stop
        the session.

        A running session of a suspended project is refused with
        `403 project_suspended` before the browser is contacted. If the project
        is suspended while the connection is open, the gateway ends it with
        WebSocket close code 1008 and reason `project_suspended`: within about
        10 seconds while traffic flows, or right after the next message on a
        connection that was silent. A connection that stays silent ends when
        the browser is stopped at its idle deadline.
      security:
        - cdpConnectionToken: []
      x-protocol: websocket
      responses:
        "101": { description: WebSocket protocol switched and CDP frames are proxied bidirectionally }
        "401": { $ref: "#/components/responses/InvalidConnectionToken" }
        "403": { $ref: "#/components/responses/ProjectSuspended" }
        "404": { $ref: "#/components/responses/ConnectionNotFound" }
        "502": { $ref: "#/components/responses/BadGateway" }

  /v1/connect/live/{sessionId}:
    parameters:
      - $ref: "#/components/parameters/SessionId"
      - name: token
        in: query
        required: true
        description: Separate connection token embedded in the returned `live_view_url`; valid until the session ends.
        schema: { type: string, writeOnly: true }
    get:
      tags: [Connections]
      operationId: connectSessionLiveViewStream
      summary: Upgrade to the constrained live-view WebSocket
      servers:
        - url: wss://connect.headlessfleet.com
          description: Beta connection gateway
      description: |
        Used by the live-view page returned for a running headful session. The
        gateway translates browser screencast frames and bounded mouse/keyboard
        input plus user-initiated UTF-8 paste messages up to 16 KiB; it never
        exposes unrestricted CDP. Only same-origin browser clients are accepted
        and WebSocket compression is disabled.

        Each screencast frame delivered to the viewer and each valid input
        forwarded to the page is session activity. Chromium sends frames only
        when the page repaints, so an open viewer on a static page without
        input is not. For a `manual` session every message in either direction
        is activity, and its display streams a frame about every 200 ms, so an
        open viewer keeps that session from idling out.

        A running session of a suspended project is refused with
        `403 project_suspended`, and an open viewer is ended with WebSocket
        close code 1008 and reason `project_suspended`, as for CDP.
      security:
        - liveViewConnectionToken: []
      x-protocol: websocket
      responses:
        "101": { description: WebSocket protocol switched and constrained live-view frames and input are proxied }
        "401": { $ref: "#/components/responses/InvalidConnectionToken" }
        "403": { $ref: "#/components/responses/ProjectSuspended" }
        "404": { $ref: "#/components/responses/ConnectionNotFound" }
        "502": { $ref: "#/components/responses/BadGateway" }

  /v1/sessions/{sessionId}/live:
    parameters:
      - $ref: "#/components/parameters/SessionId"
      - name: token
        in: query
        required: true
        description: Separate connection token embedded in the returned `live_view_url`; valid until the session ends.
        schema: { type: string, writeOnly: true }
    get:
      tags: [Connections]
      operationId: connectSessionLiveView
      summary: Open the interactive live-view page
      description: |
        Returns a no-store, CSP-confined interactive viewer for a running
        headful session. Use the complete `live_view_url` from the session;
        never construct, log, or persist this credential. The address-bar
        token is removed immediately after the page initializes. With the
        display focused, Ctrl+V (Cmd+V on macOS) transfers up to 16 KiB of
        UTF-8 plain text into the focused browser field. Loading this page is
        not session activity; the viewer's frames and input are (see the
        live-view WebSocket). A suspended project's session returns a JSON
        `403 project_suspended` error instead of the page.
      security:
        - liveViewConnectionToken: []
      responses:
        "200":
          description: Interactive headful browser viewer
          content:
            text/html:
              schema: { type: string }
        "401": { $ref: "#/components/responses/InvalidConnectionToken" }
        "403": { $ref: "#/components/responses/ProjectSuspended" }
        "404": { $ref: "#/components/responses/ConnectionNotFound" }
        "502": { $ref: "#/components/responses/BadGateway" }

  /v1/jobs:
    get:
      tags: [Jobs]
      operationId: listJobs
      summary: List browser jobs
      description: Returns at most the 1,000 most recent jobs in the authenticated project.
      x-required-scopes: [jobs:read]
      responses:
        "200":
          description: Browser jobs
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      tags: [Jobs]
      operationId: createJob
      summary: Queue a high-level browser job
      description: Jobs retry failed execution up to three bounded attempts. While the project already has twice `max_concurrent_sessions` sessions pending, a job stays queued without using an attempt and is tried again every few seconds. The default Playwright engine supports actions and artifacts; the bounded Scrapling engines support extraction and optional text or HTML through the same isolated session, proxy, metering, and queue lifecycle. Poll the job or supply an HTTPS webhook URL.
      x-required-scopes: [jobs:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateJob" }
            examples:
              extractAndScreenshot:
                value:
                  url: https://example.org/
                  engine: playwright
                  timeout_seconds: 300
                  proxy: { country: US, region: CO, city: Denver, pool: dedicated, mode: random }
                  actions:
                    - { type: wait, timeout_seconds: 2 }
                  extract:
                    - { name: title, selector: title }
                  screenshot: true
              scraplingExtraction:
                value:
                  url: https://example.org/products
                  engine: scrapling_dynamic
                  timeout_seconds: 120
                  proxy: { country: US, region: TX, city: Dallas, pool: dedicated, mode: random }
                  network_idle: true
                  disable_resources: true
                  include: text
                  extract:
                    - { name: products, selector: article.product, all: true }
      responses:
        "202":
          description: Job durably queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/jobs/{jobId}:
    parameters:
      - $ref: "#/components/parameters/JobId"
    get:
      tags: [Jobs]
      operationId: getJob
      summary: Get a browser job, result, and artifact metadata
      x-required-scopes: [jobs:read]
      responses:
        "200":
          description: Current job state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    delete:
      tags: [Jobs]
      operationId: cancelJob
      summary: Cancel a queued or running job
      description: Terminal jobs are returned unchanged, making cancellation idempotent.
      x-required-scopes: [jobs:write]
      responses:
        "200":
          description: Canceled or already-terminal job
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/artifacts/{artifactId}:
    parameters:
      - $ref: "#/components/parameters/ArtifactId"
    get:
      tags: [Jobs]
      operationId: downloadArtifact
      summary: Download an authenticated job artifact
      x-required-scopes: [jobs:read]
      responses:
        "200":
          description: Artifact bytes with the artifact content type
          headers:
            Content-Length: { schema: { type: integer, format: int64 } }
            Content-Disposition: { schema: { type: string, example: 'attachment; filename="art_example"' } }
            X-Content-Type-Options: { schema: { type: string, const: nosniff } }
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
            image/png:
              schema: { type: string, format: binary }
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/api-keys:
    get:
      tags: [API keys]
      operationId: listApiKeys
      summary: List project API keys without secret material
      x-required-scopes: [keys:write]
      responses:
        "200":
          description: API-key metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIKeyList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      tags: [API keys]
      operationId: createApiKey
      summary: Create a scoped API key
      description: The complete secret is returned exactly once. Store it immediately; only its prefix and digest remain.
      x-required-scopes: [keys:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateAPIKey" }
            example:
              name: Production scraper
              scopes: [sessions:write, profiles:write, jobs:write, usage:read]
              expires_at: "2027-01-01T00:00:00Z"
      responses:
        "201":
          description: API-key metadata and one-time secret
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedAPIKey" }
              example:
                id: key_example
                project_id: prj_example
                name: Production scraper
                prefix: cbk_example
                scopes: [sessions:write, profiles:write, jobs:write, usage:read]
                expires_at: "2027-01-01T00:00:00Z"
                created_at: "2026-07-19T12:00:00Z"
                secret: "<returned-exactly-once>"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/api-keys/{keyId}:
    parameters:
      - $ref: "#/components/parameters/KeyId"
    patch:
      tags: [API keys]
      operationId: updateApiKeyScopes
      summary: Replace an existing API key's scopes without changing its secret
      description: Restricted scopes may be retained or assigned only when the calling key already holds them.
      x-required-scopes: [keys:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scopes]
              properties:
                scopes:
                  type: array
                  minItems: 1
                  uniqueItems: true
                  items: { $ref: "#/components/schemas/APIKeyScope" }
      responses:
        "200":
          description: Updated API-key metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIKey" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    delete:
      tags: [API keys]
      operationId: revokeApiKey
      summary: Revoke an API key
      x-required-scopes: [keys:write]
      responses:
        "204": { description: API key revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/admin/api-keys:
    get:
      tags: [Administration, API keys]
      operationId: listAssignedAPIKeys
      summary: List administrator-managed user API keys
      description: Returns key metadata only. Plaintext secrets are never retained or returned after creation.
      x-required-scopes: [admin:users]
      responses:
        "200":
          description: Administrator-managed API-key metadata
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/APIKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      tags: [Administration, API keys]
      operationId: createAssignedAPIKey
      summary: Assign scoped API access to a user or integration
      description: The secret is returned exactly once. Only customer permissions may be assigned.
      x-required-scopes: [admin:users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [assigned_to, scopes]
              properties:
                assigned_to: { type: string, minLength: 1, maxLength: 200 }
                scopes:
                  type: array
                  minItems: 1
                  uniqueItems: true
                  items:
                    enum: [sessions:read, sessions:write, profiles:read, profiles:write, jobs:read, jobs:write, usage:read, billing:read, labs:browser]
                expires_at: { type: string, format: date-time }
      responses:
        "201":
          description: API-key metadata and one-time secret
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedAPIKey" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/admin/api-keys/{keyId}:
    parameters:
      - $ref: "#/components/parameters/KeyId"
    patch:
      tags: [Administration, API keys]
      operationId: updateAssignedAPIKeyPermissions
      summary: Replace a user or integration's customer API permissions
      description: Updates the endpoint scopes without changing the key secret. Platform and administrator credentials are protected from this operation.
      x-required-scopes: [admin:users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scopes]
              properties:
                scopes:
                  type: array
                  minItems: 1
                  uniqueItems: true
                  items:
                    enum: [sessions:read, sessions:write, profiles:read, profiles:write, jobs:read, jobs:write, usage:read, billing:read, labs:browser]
      responses:
        "200":
          description: Updated API-key metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIKey" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    delete:
      tags: [Administration, API keys]
      operationId: revokeAssignedAPIKey
      summary: Revoke an administrator-managed API key
      x-required-scopes: [admin:users]
      responses:
        "204": { description: API key revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/usage:
    get:
      tags: [Usage]
      operationId: getUsage
      summary: Get aggregate metered project usage
      x-required-scopes: [usage:read]
      responses:
        "200":
          description: Project usage summary
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/usage/statement:
    get:
      tags: [Usage]
      operationId: getUsageStatement
      summary: Get a period-filtered, per-session usage and credit statement
      description: Defaults to the current UTC calendar month. The period must be positive and no longer than 366 days.
      x-required-scopes: [usage:read]
      parameters:
        - name: from
          in: query
          required: false
          description: Inclusive RFC 3339 period start
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: false
          description: Exclusive RFC 3339 period end
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Reconciled usage statement
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingStatement" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/audit-events:
    get:
      tags: [Audit]
      operationId: listAuditEvents
      summary: List organization audit events
      description: Returns newest events first. Invalid or out-of-range limits use the server default of 100; the maximum is 500. Event metadata omits internal proxy endpoint identifiers, which only platform operators can read.
      x-required-scopes: [audit:read]
      parameters:
        - $ref: "#/components/parameters/ListLimit"
      responses:
        "200":
          description: Organization audit events
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditEventList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/billing:
    get:
      tags: [Billing]
      operationId: getBillingState
      summary: Get organization subscription and prepaid-credit state
      description: |
        `has_subscription` and `subscription_plan` are a copy of the
        organization's Stripe subscription state that the service updates
        whenever Stripe reports a billing event, so they can briefly trail
        Stripe. While `has_subscription` is true, change the plan on the
        existing subscription (see `POST /v1/billing/portal`); checkout
        refuses a second subscription.
      x-required-scopes: [billing:read]
      responses:
        "200":
          description: Billing state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingState" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/billing/checkout:
    post:
      tags: [Billing]
      operationId: createBillingCheckout
      summary: Create a Stripe-hosted subscription checkout
      description: |
        Starts a subscription for an organization that does not have one.
        Use a plan whose `checkout_available` is true in `GET /v1/plans`.
        Before creating the checkout, the server links a Stripe customer to the
        organization if it has none, expires the customer's other open
        subscription checkouts so an older checkout link can no longer be
        completed, and asks Stripe for the customer's subscriptions. While one
        can still bill (active, trialing, past due, unpaid, incomplete, or
        paused), checkout is refused with `409 subscription_exists`; change the
        plan on the existing subscription instead (see
        `POST /v1/billing/portal`). The plan changes when Stripe
        reports the completed checkout, not when this URL is created.
      x-required-scopes: [billing:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateCheckout" }
            example: { plan: growth }
      responses:
        "201":
          description: Short-lived Stripe checkout URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HostedURL" }
        "400":
          $ref: "#/components/responses/BadRequest"
          description: The JSON body is invalid, or the plan has no Stripe price on this deployment (`plan_unavailable`), for example `free`, a quote-based plan, or an unknown plan ID
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: The organization already has a subscription that can still bill; change the plan on it instead (see `POST /v1/billing/portal`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }
              example: { error: { code: subscription_exists, message: "This organization already has a subscription. To switch plans, use Change plan to open the billing portal instead of starting a new checkout." } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "502": { $ref: "#/components/responses/BadGateway" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/billing/portal:
    post:
      tags: [Billing]
      operationId: createBillingPortal
      summary: Create a Stripe customer-portal session
      description: |
        Opens Stripe's customer portal for the organization's Stripe customer
        (`stripe_customer_id` in `GET /v1/billing`), where an organization
        manages its existing subscription; plan changes never go through a
        second checkout. The portal offers what its Stripe configuration
        enables, such as updating the payment method, viewing invoices,
        cancelling, and switching plans. When it doesn't offer the plan
        switch you want, contact support to have the subscription changed. A
        plan change takes effect when Stripe reports it; the plan is then
        read from the subscription's current price, and every project in the
        organization moves to it. Fails with `502 billing_provider_error`
        when the organization has no Stripe customer yet.
      x-required-scopes: [billing:write]
      responses:
        "201":
          description: Short-lived Stripe portal URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HostedURL" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "502": { $ref: "#/components/responses/BadGateway" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/billing/topups:
    post:
      tags: [Billing]
      operationId: createBillingTopUp
      summary: Create a Stripe-hosted prepaid-credit top-up
      description: Amount is in currency micros and must represent whole cents between $5 and $10,000.
      x-required-scopes: [billing:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateTopUp" }
            example: { amount_micros: 25000000 }
      responses:
        "201":
          description: Short-lived Stripe checkout URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HostedURL" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/billing/transactions:
    get:
      tags: [Billing]
      operationId: listBillingTransactions
      summary: List prepaid top-ups, usage debits, adjustments, reconciliation, and refunds
      description: Returns newest transactions first. Invalid or out-of-range limits use 100; the maximum is 500.
      x-required-scopes: [billing:read]
      parameters:
        - $ref: "#/components/parameters/ListLimit"
      responses:
        "200":
          description: Credit transactions
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreditTransactionList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/workers:
    get:
      tags: [Administration]
      operationId: listWorkers
      summary: List browser worker capacity and health
      x-required-scopes: [admin:workers]
      responses:
        "200":
          description: Registered workers
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkerList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/workers/{workerId}/drain:
    parameters:
      - $ref: "#/components/parameters/WorkerId"
    post:
      tags: [Administration]
      operationId: setWorkerDrain
      summary: Drain or resume a worker
      description: Draining prevents new reservations. An optional future deadline enables forced maintenance handling.
      x-required-scopes: [admin:workers]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DrainWorker" }
      responses:
        "200":
          description: Updated worker
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Worker" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/workers/{workerId}/capacity-policy:
    parameters:
      - $ref: "#/components/parameters/WorkerId"
    patch:
      tags: [Administration]
      operationId: updateWorkerCapacityPolicy
      summary: Live-update worker admission and warm capacity
      description: Applies on the next worker heartbeat. Reducing admission never terminates running sessions. Resident ceiling cannot exceed the worker's process-time hard capacity.
      x-required-scopes: [admin:workers]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorkerCapacityPolicy" }
      responses:
        "200":
          description: Worker with the persisted capacity policy
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Worker" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/admin/projects/{projectId}/capacity:
    parameters:
      - name: projectId
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: [Administration]
      operationId: updateProjectCapacity
      summary: Live-update a project's session concurrency
      description: A lower limit only affects new admissions. Send null to return to the plan limit. The project may have twice its limit of sessions pending.
      x-required-scopes: [admin:workers]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [max_concurrent_sessions]
              properties:
                max_concurrent_sessions:
                  oneOf:
                    - { type: integer, minimum: 1, maximum: 10000 }
                    - { type: "null" }
      responses:
        "200":
          description: Updated project with effective entitlements
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/proxy-endpoints:
    get:
      tags: [Administration, Proxies]
      operationId: listProxyEndpoints
      summary: List internal proxy and SIM inventory
      description: Restricted administrative view containing provider accounts, cap usage, leases, and canary health. Never expose this route through customer dashboards.
      x-required-scopes: [admin:proxies]
      responses:
        "200":
          description: Administrative proxy endpoint inventory
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProxyEndpointList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/proxy-endpoints/{proxyEndpointId}:
    parameters:
      - name: proxyEndpointId
        in: path
        required: true
        schema: { type: string }
    put:
      tags: [Administration, Proxies]
      operationId: upsertProxyEndpoint
      summary: Add or replace a proxy endpoint without restarting the control plane
      description: Validates and encrypts credentials before durable storage. Credentials are write-only and never returned.
      x-required-scopes: [admin:proxies]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProxyInventoryInput" }
      responses:
        "200":
          description: Redacted durable proxy endpoint
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProxyEndpoint" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /webhooks/stripe:
    post:
      tags: [Webhooks]
      operationId: receiveStripeWebhook
      summary: Receive a signature-verified Stripe event
      description: |
        Provider callback only. Events are idempotently claimed by Stripe event
        ID before ledger mutation. Subscription and invoice events, and
        completed subscription checkouts, re-read the customer's complete
        subscription list from Stripe rather than applying the event payload,
        so duplicate and out-of-order deliveries converge.
        Events for an organization that no longer exists are acknowledged
        without changes. A signature failure and a processing failure both
        currently answer `400 invalid_stripe_webhook`.
      security: []
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Event accepted or previously processed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookAccepted" }
              example: { received: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

webhooks:
  jobCompleted:
    post:
      tags: [Webhooks]
      operationId: receiveJobCompletedWebhook
      summary: Receive a signed terminal job event
      description: |
        This describes the request Cloud Browser sends to the HTTPS
        `webhook_url` supplied when a job is created. Delivery is attempted up
        to five times with bounded exponential backoff. Verify the lowercase
        hexadecimal HMAC-SHA256 signature over the exact request bytes using
        the configured webhook signing secret.
      security: []
      parameters:
        - name: User-Agent
          in: header
          required: true
          schema: { type: string, const: CloudBrowser-Webhooks/1.0 }
        - name: X-CloudBrowser-Signature
          in: header
          required: true
          description: "`sha256=` followed by the lowercase hexadecimal HMAC-SHA256."
          schema: { type: string, pattern: '^sha256=[0-9a-f]{64}$' }
        - name: X-CloudBrowser-Event-ID
          in: header
          required: true
          description: Stable job ID and terminal status; use it for receiver idempotency.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JobWebhookEvent" }
      responses:
        "200": { description: Any 2xx response acknowledges delivery }

components:
  securitySchemes:
    bearerApiKey:
      type: http
      scheme: bearer
      bearerFormat: project API key
      description: Project API key returned once by `POST /v1/api-keys`.
    cdpConnectionToken:
      type: apiKey
      in: query
      name: token
      description: Token embedded in a running session's complete `cdp_url`. It stays valid until the session ends, even if the API key that created the session is revoked; stopping the session revokes it.
    liveViewConnectionToken:
      type: apiKey
      in: query
      name: token
      description: Separate token embedded in a running headful session's complete `live_view_url`. It stays valid until the session ends; stopping the session revokes it.

  parameters:
    ProfileId:
      name: profileId
      in: path
      required: true
      schema: { type: string }
    SessionId:
      name: sessionId
      in: path
      required: true
      schema: { type: string }
    JobId:
      name: jobId
      in: path
      required: true
      schema: { type: string }
    ArtifactId:
      name: artifactId
      in: path
      required: true
      schema: { type: string }
    KeyId:
      name: keyId
      in: path
      required: true
      schema: { type: string }
    WorkerId:
      name: workerId
      in: path
      required: true
      schema: { type: string }
    ListLimit:
      name: limit
      in: query
      required: false
      description: Maximum records. Invalid values use the server default.
      schema: { type: integer, minimum: 1, maximum: 500, default: 100 }

  headers:
    RateLimitLimit:
      description: Effective per-credential request limit for the current window.
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    RateLimitReset:
      description: Seconds until the current standard rate-limit window resets.
      schema: { type: integer }
    RetryAfter:
      description: Seconds before retrying.
      schema: { type: integer, minimum: 1 }

  responses:
    BadRequest:
      description: Request JSON, parameters, or resource requirements are invalid
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
          example: { error: { code: invalid_json, message: "request body is invalid" } }
    Unauthorized:
      description: Bearer authentication is missing, invalid, or unavailable
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
          example: { error: { code: invalid_api_key, message: API key is invalid } }
    PaymentRequired:
      description: Prepaid credit is required before another browser can be admitted (`prepaid_credit_required`). The project has used this month's included browser time or proxy data and the organization's credit balance is not positive; nothing is queued
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
          example: { error: { code: prepaid_credit_required, message: Add prepaid credit before starting another browser session } }
    Forbidden:
      description: The credential lacks scope (`insufficient_scope`), the region is not allowed, or the project is suspended (`project_suspended`, returned for every authenticated request made with an API key of a suspended project)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
          example: { error: { code: insufficient_scope, message: The authenticated principal does not have the required scope } }
    ProjectSuspended:
      description: The session belongs to a suspended project; the browser is not contacted
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
          example: { error: { code: project_suspended, message: project is suspended } }
    NotFound:
      description: Resource was not found in the authenticated project
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
          example: { error: { code: session_not_found, message: Session was not found } }
    Conflict:
      description: Resource state conflicts with the requested transition
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
          example: { error: { code: profile_in_use, message: Only one writable session may use a profile } }
    RateLimited:
      description: >-
        Per-key or per-organization request limit reached (rate_limit_reached).
        On session creates and retries, also too_many_queued_sessions: the
        project already has twice its max_concurrent_sessions pending. That
        refusal sends no rate-limit or Retry-After headers; retry once a
        pending session has started or ended.
      headers:
        RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
        RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
        RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
          example: { error: { code: rate_limit_reached, message: API request rate limit reached } }
    BadGateway:
      description: An upstream browser or billing provider failed
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
    ServiceUnavailable:
      description: A required service is unavailable, the queue is full, or admission failed
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
    InternalError:
      description: Unexpected internal failure
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
          example: { error: { code: internal_error, message: An internal error occurred } }
    InvalidConnectionToken:
      description: Session connection token is invalid
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
          example: { error: { code: invalid_connection_token, message: Connection token is invalid } }
    ConnectionNotFound:
      description: Session gateway is unavailable
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
          example: { error: { code: session_gateway_not_found, message: Session gateway is unavailable } }

  schemas:
    Problem:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            retry_after_seconds: { type: integer, minimum: 0 }

    Health:
      type: object
      required: [status]
      properties:
        status: { type: string, const: ok }

    Entitlements:
      type: object
      required: [max_concurrent_sessions, max_profiles, max_session_seconds, included_browser_seconds, included_proxy_bytes, browser_rate_micros_per_hour, proxy_rate_micros_per_gb, allowed_regions, api_requests_per_minute]
      properties:
        max_concurrent_sessions: { type: integer, minimum: 0 }
        max_profiles: { type: integer, minimum: 0 }
        max_session_seconds: { type: integer, minimum: 0 }
        included_browser_seconds: { type: integer, format: int64, minimum: 0 }
        included_proxy_bytes: { type: integer, format: int64, minimum: 0 }
        browser_rate_micros_per_hour: { type: integer, format: int64, minimum: 0 }
        proxy_rate_micros_per_gb: { type: integer, format: int64, minimum: 0 }
        allowed_regions: { type: array, items: { type: string } }
        api_requests_per_minute: { type: integer, minimum: 0 }

    Plan:
      type: object
      required: [id, display_name, description, monthly_fee_micros, self_serve, checkout_available, entitlements]
      properties:
        id: { type: string, example: developer }
        display_name: { type: string, example: Developer }
        description: { type: string }
        monthly_fee_micros:
          type: integer
          format: int64
          description: Monthly subscription fee in micro-dollars (49000000 = USD 49). Zero for free and quote-based plans.
        self_serve: { type: boolean, description: True when a customer can select the plan without contacting sales. }
        checkout_available:
          type: boolean
          description: True when Stripe checkout is configured for the plan. When billing is configured, checkout refuses every other plan with `400 plan_unavailable`. The flag does not reflect whether the caller's organization already has a subscription; checkout then answers `409 subscription_exists`.
        entitlements: { $ref: "#/components/schemas/Entitlements" }

    Project:
      type: object
      required: [id, organization_id, name, plan, entitlements, suspended]
      properties:
        id: { type: string }
        organization_id: { type: string }
        name: { type: string }
        plan:
          type: string
          description: Follows the organization's subscription. When a subscription starts to entitle a plan or a plan change on it takes effect, every project in the organization moves to that plan; when the subscription stops entitling it, projects on a plan that checkout sells move to `free`.
        entitlements: { $ref: "#/components/schemas/Entitlements" }
        suspended:
          type: boolean
          description: True while the project is suspended by the service operators. Every authenticated request made with the project's API keys is then refused with `403 project_suspended`, so only a signed-in dashboard user can read this flag; the dashboard keeps read, payment, and wind-down access. Unrelated to `subscription_status` in billing state.

    ProxyMode:
      type: string
      enum: [random, sticky]
    ProxyPool:
      type: string
      enum: [dedicated, shared]
      description: A dedicated endpoint has one active lease globally. A shared endpoint permits one active lease per API key and is reusable concurrently by different API keys up to the operator cap.
    ProxyRequest:
      type: object
      additionalProperties: false
      description: |
        When pool is omitted it resolves to `dedicated` where the requested
        location has dedicated inventory and to `shared` otherwise; the
        session and profile report the resolved pool. Ephemeral sessions
        default to `random` and reject sticky mode. Saved profiles require
        `sticky`; their resolved location, carrier, and pool cannot be changed
        when launched.
      properties:
        country:
          type: string
          minLength: 2
          maxLength: 2
          pattern: '^[A-Za-z]{2}$'
          default: US
        region: { type: string, maxLength: 64, description: State or region; normalized to uppercase }
        city: { type: string, maxLength: 128, description: Exact city; requires region }
        carrier: { type: string, maxLength: 128, description: Optional provider-advertised carrier. Production saved profiles resolve an omitted carrier from verified inventory and persist the carrier confirmed at launch. }
        mode: { $ref: "#/components/schemas/ProxyMode" }
        pool: { $ref: "#/components/schemas/ProxyPool" }
    RetargetProxyRequest:
      type: object
      required: [country, pool]
      additionalProperties: false
      description: Full replacement for a pending ephemeral session's proxy requirement. Country and pool are required; mode is random when omitted and cannot be sticky.
      properties:
        country:
          type: string
          minLength: 2
          maxLength: 2
          pattern: '^[A-Za-z]{2}$'
        region: { type: string, maxLength: 64, description: State or region; normalized to uppercase }
        city: { type: string, maxLength: 128, description: Requires region when set }
        carrier: { type: string, maxLength: 128, description: Optional provider-advertised carrier }
        mode: { type: string, enum: [random], default: random }
        pool: { $ref: "#/components/schemas/ProxyPool" }
    RetrySessionRequest:
      type: object
      additionalProperties: false
      description: Replacement admission settings for a failed ephemeral session. Omit proxy to inherit the predecessor's resolved location, carrier, and pool with random allocation.
      properties:
        proxy:
          allOf:
            - $ref: "#/components/schemas/RetargetProxyRequest"
          description: Optional complete proxy replacement. Partial proxy objects are rejected.
        queue_timeout_seconds:
          type: integer
          minimum: 1
          maximum: 600
          description: Optional queue deadline duration for the replacement session; when omitted, the predecessor's configured queue window is reused.
    ProxyLease:
      type: object
      required: [id, provider, country, mode, pool, expires_at]
      properties:
        id: { type: string }
        provider:
          type: string
          enum: [headlessfleet]
          description: Always `headlessfleet`, including before a lease is assigned.
        account:
          type: string
          deprecated: true
          description: No longer returned. Proxy endpoint identity is internal to HeadlessFleet.
        country: { type: string, minLength: 2, maxLength: 2 }
        region: { type: string }
        city: { type: string }
        timezone: { type: string, description: Provider-authoritative IANA timezone }
        carrier: { type: string, description: Provider-authoritative carrier assigned to this lease }
        mode: { $ref: "#/components/schemas/ProxyMode" }
        pool: { $ref: "#/components/schemas/ProxyPool" }
        expires_at: { type: string, format: date-time }
    ProxyLocationOperation:
      type: string
      enum: [session_create, profile_create, retarget, retry, relocate]
    ProxyLocation:
      type: object
      required: [country, pool, modes, operations, carriers, capacity, provisioned_capacity, available_capacity, active_capacity, sticky_reserved_capacity, rotation_blocked_capacity, health_blocked_capacity, usage_blocked_capacity, unavailable_reasons, available]
      properties:
        country: { type: string, minLength: 2, maxLength: 2 }
        region: { type: string }
        city: { type: string }
        timezone: { type: string, description: Provider-authoritative IANA timezone }
        pool: { $ref: "#/components/schemas/ProxyPool" }
        modes:
          type: array
          minItems: 1
          uniqueItems: true
          description: Proxy identity modes accepted for this location; sticky still requires a saved profile.
          items: { $ref: "#/components/schemas/ProxyMode" }
        operations:
          type: array
          minItems: 1
          uniqueItems: true
          description: Customer workflows supported by this location. Restricted operations still require their documented scope.
          items: { $ref: "#/components/schemas/ProxyLocationOperation" }
        carriers:
          type: array
          uniqueItems: true
          description: Carrier choices with positive provider availability, sourced from the provider catalog or aggregated from live provider inventory when the catalog omits carriers. Empty when no provider-reported carrier metadata is available. Proxy identity and credentials are never exposed.
          items: { type: string, minLength: 1, maxLength: 128 }
        capacity:
          type: integer
          minimum: 0
          description: Generic concurrency not blocked by sticky affinity, health, rotation, or usage limits; includes active and free slots.
        provisioned_capacity: { type: integer, minimum: 0, description: Aggregate configured concurrency before live admission blockers. }
        available_capacity: { type: integer, minimum: 0, description: Generic slots free at response time; does not reserve capacity. }
        active_capacity: { type: integer, minimum: 0, description: Generic slots currently leased to active or starting sessions. }
        sticky_reserved_capacity: { type: integer, minimum: 0, description: Dedicated slots held for saved-profile affinity; tenant and endpoint identity are never exposed. }
        rotation_blocked_capacity: { type: integer, minimum: 0 }
        health_blocked_capacity: { type: integer, minimum: 0 }
        usage_blocked_capacity: { type: integer, minimum: 0 }
        unavailable_reasons:
          type: array
          uniqueItems: true
          items:
            type: string
            enum: [sticky_reserved, active_leases, rotation_queued, rotation_in_progress, rotation_failed, health_cooldown, endpoint_disabled, endpoint_unhealthy, usage_limit]
        available: { type: boolean, description: True when at least one eligible slot is free in this location and pool at response time; does not reserve capacity. }
    ProxyLocationList:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/ProxyLocation" } }
    ProxyRelocationLocation:
      type: object
      required: [country, region, city, timezone]
      additionalProperties: false
      properties:
        country: { type: string, minLength: 2, maxLength: 2 }
        region: { type: string }
        city: { type: string }
        timezone: { type: string, description: Provider-authoritative IANA timezone }
    ProxyRelocationLocationList:
      type: object
      required: [data]
      additionalProperties: false
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/ProxyRelocationLocation" } }

    CreateProfile:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1 }
        session_id: { type: string, description: Running ephemeral session to convert into this profile. Mutually exclusive with proxy. }
        proxy: { $ref: "#/components/schemas/ProxyRequest", description: Initial sticky requirement for an empty profile. Must be omitted when session_id is supplied. }
    Profile:
      type: object
      required: [id, project_id, name, proxy_country, proxy_mode, proxy_pool, snapshot_version, created_at, updated_at]
      properties:
        id: { type: string }
        project_id: { type: string }
        name: { type: string }
        proxy_country: { type: string }
        proxy_region: { type: string }
        proxy_city: { type: string }
        proxy_carrier: { type: string, description: "Carrier resolved from verified inventory at profile creation, confirmed on first successful launch, and enforced on restore" }
        proxy_mode: { type: string, const: sticky }
        proxy_pool: { $ref: "#/components/schemas/ProxyPool" }
        proxy_lease_id: { type: string }
        snapshot_version: { type: integer, format: int64, minimum: 0 }
        active_session_id: { type: string }
        deletion_scheduled_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ProfileList:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Profile" } }

    Viewport:
      type: object
      required: [width, height]
      additionalProperties: false
      properties:
        width: { type: integer, minimum: 800, maximum: 8192, default: 1440, description: Normal sessions are capped at 1920; larger values require an authorized lab screen override }
        height: { type: integer, minimum: 600, maximum: 8192, default: 900, description: Normal sessions are capped at 1080; larger values require an authorized lab screen override }
    LabScreen:
      type: object
      required: [width, height, avail_width, avail_height, color_depth]
      additionalProperties: false
      properties:
        width: { type: integer, minimum: 320, maximum: 8192 }
        height: { type: integer, minimum: 240, maximum: 8192 }
        avail_width: { type: integer, minimum: 1, maximum: 8192, description: Must not exceed width }
        avail_height: { type: integer, minimum: 1, maximum: 8192, description: Must not exceed height }
        color_depth: { type: integer, const: 24 }
    LabOverrides:
      type: object
      required: [screen]
      additionalProperties: false
      description: Non-production allowlisted browser configuration requiring labs:browser.
      properties:
        screen: { $ref: "#/components/schemas/LabScreen" }
        device_template_id: { type: string, enum: [linux_desktop_reference_v1], default: linux_desktop_reference_v1 }
    Resources:
      type: object
      required: [vcpus, memory_mib, disk_mib, network_mbps]
      properties:
        vcpus: { type: integer, minimum: 0 }
        memory_mib: { type: integer, format: int64, minimum: 0 }
        disk_mib: { type: integer, format: int64, minimum: 0 }
        network_mbps: { type: integer, format: int64, minimum: 0 }
    SessionUsage:
      type: object
      required: [browser_seconds, proxy_bytes, proxy_bytes_up, proxy_bytes_down, checkpoint_sequence]
      properties:
        browser_seconds: { type: integer, format: int64, minimum: 0 }
        proxy_bytes: { type: integer, format: int64, minimum: 0 }
        proxy_bytes_up: { type: integer, format: int64, minimum: 0 }
        proxy_bytes_down: { type: integer, format: int64, minimum: 0 }
        checkpoint_sequence: { type: integer, format: int64, minimum: 0 }
    Automation:
      type: object
      required: [mode]
      additionalProperties: false
      properties:
        mode:
          type: string
          enum: [standard_cdp, stealth_cdp, manual]
          default: standard_cdp
          description: standard_cdp uses the conventional CDP contract; stealth_cdp uses an alternative Chromium launch configuration and a minimal-domain CDP integration; manual requires display_mode headful, exposes only native-display control, and starts no CDP listener.
    AutomationRequest:
      type: object
      additionalProperties: false
      properties:
        mode:
          type: string
          enum: [standard_cdp, stealth_cdp, manual]
          default: standard_cdp
          description: Omit to use standard_cdp.
    CreateSession:
      type: object
      additionalProperties: false
      properties:
        profile_id: { type: string, description: Omit for an ephemeral browser }
        timeout_seconds: { type: integer, format: int64, minimum: 30, default: 600, description: Must not exceed the project entitlement }
        idle_timeout_seconds:
          type: integer
          format: int64
          minimum: 30
          maximum: 600
          default: 120
          description: At most 600 (10 minutes) and must not exceed timeout_seconds; other values return `400 invalid_idle_timeout`. A running session with no activity and no heartbeat for this long is stopped as `timed_out` with failure code `idle_timeout`. CDP data messages and live-view frames or input through the session's returned URLs count as activity. To wait longer without traffic, for example on a slow model call, send `POST /v1/sessions/{sessionId}/heartbeat`.
        region: { type: string, description: Defaults to the project's first allowed region }
        viewport: { $ref: "#/components/schemas/Viewport" }
        display_mode: { type: string, enum: [headless, headful], default: headless }
        automation: { $ref: "#/components/schemas/AutomationRequest" }
        lab_overrides: { $ref: "#/components/schemas/LabOverrides" }
        proxy: { $ref: "#/components/schemas/ProxyRequest" }
        queue_timeout_seconds: { type: integer, format: int64, minimum: 1, maximum: 600, default: 120 }
    CreateSessionBatch:
      type: object
      required: [sessions]
      additionalProperties: false
      properties:
        sessions:
          type: array
          minItems: 1
          maxItems: 125
          items: { $ref: "#/components/schemas/CreateSession" }
    StopSessionBatch:
      type: object
      required: [session_ids]
      additionalProperties: false
      properties:
        session_ids:
          type: array
          minItems: 1
          maxItems: 125
          uniqueItems: true
          items: { type: string, minLength: 1, maxLength: 128 }
    ExtendSession:
      type: object
      required: [timeout_seconds]
      additionalProperties: false
      properties:
        timeout_seconds: { type: integer, format: int64, minimum: 30 }
    ProxyFailureReport:
      type: object
      required: [failure_code]
      additionalProperties: false
      properties:
        failure_code: { type: string, enum: [tunnel_connection_failed] }
    ProxyRelocateInput:
      type: object
      required: [country, region, city]
      additionalProperties: false
      properties:
        country: { type: string, pattern: "^[A-Za-z]{2}$", example: US }
        region: { type: string, minLength: 1, maxLength: 64, example: TX }
        city: { type: string, minLength: 1, maxLength: 128, example: Dallas }
        carrier: { type: string, maxLength: 128, description: Optional carrier for the relocated proxy, example: Verizon }
    ProxyActionResult:
      type: object
      required: [action, status]
      additionalProperties: false
      properties:
        action: { type: string, enum: [rotate, relocate, replace], description: "`replace` reports a shared session moved to another endpoint of the same location and carrier" }
        status: { type: string, enum: [accepted, complete, pending] }
        country: { type: string }
        region: { type: string }
        city: { type: string }
        timezone: { type: string, description: Provider-authoritative IANA timezone }
        carrier: { type: string, description: Provider-authoritative carrier after the operation }
    Session:
      type: object
      required: [id, project_id, browser_mode, display_mode, automation, status, region, viewport, proxy, requested_lifetime_seconds, idle_timeout_seconds, created_at, expires_at, idle_deadline, last_heartbeat, queue_deadline, start_attempts, max_start_attempts, resources, usage]
      properties:
        id: { type: string }
        project_id: { type: string }
        profile_id: { type: string }
        browser_mode: { type: string, readOnly: true, description: Opaque operator-selected browser implementation; customer requests cannot use this field to select an internal runtime }
        display_mode: { type: string, enum: [headless, headful] }
        automation: { $ref: "#/components/schemas/Automation" }
        status: { type: string, enum: [pending, starting, running, stopping, completed, timed_out, failed, canceled] }
        region: { type: string }
        viewport: { $ref: "#/components/schemas/Viewport" }
        lab_override: { type: boolean, readOnly: true }
        lab_overrides: { $ref: "#/components/schemas/LabOverrides" }
        proxy: { $ref: "#/components/schemas/ProxyLease" }
        requested_lifetime_seconds: { type: integer, format: int64 }
        idle_timeout_seconds: { type: integer, format: int64 }
        created_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time }
        api_to_ready_latency_ms: { type: integer, format: int64, minimum: 0, readOnly: true, description: Milliseconds from durable API admission (`created_at`) until the browser and its requested connection surface became ready (`started_at`). Present after the session first reaches running. }
        expires_at:
          type: string
          format: date-time
          description: Hard stop time, reset when the session reaches running. Only the extend operation moves it; activity and heartbeats never do.
        idle_deadline:
          type: string
          format: date-time
          description: When a running session is stopped for inactivity. CDP and live-view activity (saved with a delay of up to about 10 seconds) and heartbeats move it to the activity time plus `idle_timeout_seconds`. Neither ever moves it earlier, and neither moves it while the project is suspended.
        last_heartbeat:
          type: string
          format: date-time
          description: Latest recorded heartbeat or connection activity. Usage checkpoints can also advance it, so use `idle_deadline` to predict an idle stop.
        ended_at: { type: string, format: date-time }
        cdp_url: { type: string, format: uri, readOnly: true, description: WebSocket URL whose connection token stays valid until the session ends; present only for running CDP modes; omitted for manual sessions; treat as a credential }
        live_view_url: { type: string, format: uri, readOnly: true, description: Interactive URL for a running headful session with its own connection token that stays valid until the session ends; treat as a credential; manual mode captures the complete guest display including browser chrome without CDP }
        failure_code:
          type: string
          description: Machine-readable terminal reason. A `timed_out` session has `idle_timeout` (it went idle first) or `session_timeout` (it reached `expires_at`); a pending or starting session of a suspended project fails with `project_suspended`.
        queue_reason: { type: string, enum: [proxy_capacity, project_capacity, worker_capacity], readOnly: true, description: Latest observed non-terminal admission blocker. It is cleared when dispatch advances or the proxy target changes. }
        worker_id: { type: string, readOnly: true }
        browser_id: { type: string, readOnly: true, description: Opaque worker image/version identifier snapshotted when a worker is assigned; absent while unassigned }
        browser_image_digest: { type: string, readOnly: true, description: Immutable worker-selected browser image provenance; this is not the Chrome executable hash and is absent while unassigned }
        allocation_id: { type: string, readOnly: true }
        fencing_token: { type: integer, format: int64, readOnly: true }
        queue_deadline: { type: string, format: date-time }
        start_attempts: { type: integer, minimum: 0 }
        max_start_attempts: { type: integer, minimum: 1 }
        resources: { $ref: "#/components/schemas/Resources" }
        usage: { $ref: "#/components/schemas/SessionUsage" }
    SessionList:
      type: object
      required: [data, counts]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Session" } }
        counts: { $ref: "#/components/schemas/SessionStatusCounts" }
        unmatched_ids:
          type: array
          items: { type: string }
          description: Present only when ids were requested; includes IDs that are not project-visible or did not match the optional status filter.
    SessionBatchError:
      type: object
      required: [code, message]
      additionalProperties: false
      properties:
        code: { type: string }
        message: { type: string }
    SessionBatchResult:
      type: object
      required: [index, http_status]
      additionalProperties: false
      properties:
        index: { type: integer, minimum: 0, description: Zero-based input position }
        http_status: { type: integer, minimum: 100, maximum: 599 }
        session: { $ref: "#/components/schemas/Session" }
        error: { $ref: "#/components/schemas/SessionBatchError" }
    SessionBatchResponse:
      type: object
      required: [data]
      additionalProperties: false
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/SessionBatchResult" }

    SessionStatusCounts:
      type: object
      required: [pending, starting, running, stopping, completed, timed_out, failed, canceled, live, active, total]
      properties:
        pending: { type: integer, minimum: 0 }
        starting: { type: integer, minimum: 0 }
        running: { type: integer, minimum: 0 }
        stopping: { type: integer, minimum: 0 }
        completed: { type: integer, minimum: 0 }
        timed_out: { type: integer, minimum: 0 }
        failed: { type: integer, minimum: 0 }
        canceled: { type: integer, minimum: 0 }
        live: { type: integer, minimum: 0, description: "Pending, starting, running, and stopping sessions." }
        active: { type: integer, minimum: 0, description: "Starting, running, and stopping sessions that consume project concurrency." }
        total: { type: integer, minimum: 0 }

    APIKeyScope:
      type: string
      enum: [sessions:read, sessions:write, profiles:read, profiles:write, jobs:read, jobs:write, usage:read, keys:write, audit:read, billing:read, billing:write, admin:users, admin:workers, admin:proxies, labs:browser, labs:proxy]
    CreateAPIKey:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1 }
        scopes:
          type: array
          uniqueItems: true
          description: Defaults to sessions:write, profiles:write, jobs:write, and usage:read.
          items: { $ref: "#/components/schemas/APIKeyScope" }
        expires_at: { type: string, format: date-time, description: Must be in the future }
    APIKey:
      type: object
      required: [id, project_id, name, prefix, scopes, created_at]
      properties:
        id: { type: string }
        project_id: { type: string }
        name: { type: string }
        prefix: { type: string, description: Non-secret display prefix }
        scopes: { type: array, items: { $ref: "#/components/schemas/APIKeyScope" } }
        last_used_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        revoked_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    CreatedAPIKey:
      allOf:
        - $ref: "#/components/schemas/APIKey"
        - type: object
          required: [secret]
          properties:
            secret: { type: string, readOnly: true, description: Returned exactly once; treat as a credential }
    APIKeyList:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/APIKey" } }

    UsageSummary:
      type: object
      required: [browser_seconds, proxy_bytes, profile_storage_bytes, cost_micros]
      properties:
        browser_seconds: { type: integer, format: int64, minimum: 0 }
        proxy_bytes: { type: integer, format: int64, minimum: 0 }
        profile_storage_bytes: { type: integer, format: int64, minimum: 0 }
        cost_micros: { type: integer, format: int64 }
    BillingSessionLine:
      type: object
      required: [session_id, browser_seconds, upload_bytes, download_bytes, browser_cost_micros, proxy_cost_micros, total_cost_micros]
      properties:
        session_id: { type: string }
        browser_seconds: { type: integer, format: int64 }
        upload_bytes: { type: integer, format: int64 }
        download_bytes: { type: integer, format: int64 }
        browser_cost_micros: { type: integer, format: int64 }
        proxy_cost_micros: { type: integer, format: int64 }
        total_cost_micros: { type: integer, format: int64 }
    CreditTransaction:
      type: object
      required: [id, organization_id, kind, amount_micros, balance_micros, idempotency_key, created_at]
      properties:
        id: { type: string }
        organization_id: { type: string }
        project_id: { type: string }
        session_id: { type: string }
        kind: { type: string, enum: [topup, usage, adjustment, reconciliation, refund] }
        amount_micros: { type: integer, format: int64, description: Signed ledger delta }
        balance_micros: { type: integer, format: int64 }
        idempotency_key: { type: string }
        metadata: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
    CreditTransactionList:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/CreditTransaction" } }
    BillingStatement:
      type: object
      required: [project_id, organization_id, from, to, currency, sessions, transactions, browser_seconds, upload_bytes, download_bytes, usage_cost_micros, credits_micros, adjustments_micros]
      properties:
        project_id: { type: string }
        organization_id: { type: string }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        currency: { type: string }
        sessions: { type: array, items: { $ref: "#/components/schemas/BillingSessionLine" } }
        transactions: { type: array, items: { $ref: "#/components/schemas/CreditTransaction" } }
        browser_seconds: { type: integer, format: int64 }
        upload_bytes: { type: integer, format: int64 }
        download_bytes: { type: integer, format: int64 }
        usage_cost_micros: { type: integer, format: int64 }
        credits_micros: { type: integer, format: int64 }
        adjustments_micros: { type: integer, format: int64 }

    AuditEvent:
      type: object
      required: [id, organization_id, actor_type, actor_id, action, resource_type, created_at]
      properties:
        id: { type: string }
        organization_id: { type: string }
        project_id: { type: string }
        actor_type: { type: string }
        actor_id: { type: string }
        action: { type: string }
        resource_type: { type: string }
        resource_id: { type: string }
        metadata: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
    AuditEventList:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/AuditEvent" } }

    JobAction:
      type: object
      required: [type]
      additionalProperties: false
      properties:
        type: { type: string, enum: [click, type, select, wait] }
        selector: { type: string, description: Required except for wait }
        value: { type: string, description: Text or selected value for type/select }
        timeout_seconds: { type: integer, format: int64, minimum: 1 }
    ExtractRequest:
      type: object
      required: [name, selector]
      additionalProperties: false
      properties:
        name: { type: string }
        selector: { type: string }
        attribute: { type: string, description: Omit to extract text content }
        all: { type: boolean, default: false }
    CreateJob:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url: { type: string, format: uri, description: Public HTTP or HTTPS URL; private and metadata destinations are rejected }
        engine:
          type: string
          enum: [playwright, scrapling_dynamic, scrapling_stealthy]
          default: playwright
          description: Playwright supports actions, screenshots, PDFs, and profiles. Scrapling engines support bounded extraction only.
        profile_id: { type: string }
        region: { type: string }
        timeout_seconds: { type: integer, format: int64, minimum: 30, description: Defaults to 600 for Playwright and 120 for Scrapling; Scrapling is capped at 120 }
        proxy: { $ref: "#/components/schemas/ProxyRequest" }
        actions: { type: array, items: { $ref: "#/components/schemas/JobAction" } }
        extract: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ExtractRequest" } }
        wait_selector: { type: string, maxLength: 512, description: Scrapling only; wait for this CSS selector before extraction }
        network_idle: { type: boolean, default: false, description: Scrapling only; wait for network stability }
        disable_resources: { type: boolean, default: false, description: Scrapling only; omit nonessential browser resources }
        include: { type: string, enum: [none, text, html], default: none, description: Scrapling only; include bounded page content in the result }
        screenshot: { type: boolean, default: false }
        pdf: { type: boolean, default: false }
        webhook_url: { type: string, format: uri, description: Public HTTPS URL for signed terminal delivery }
    Artifact:
      type: object
      required: [id, job_id, kind, content_type, object_key, size_bytes, sha256, created_at]
      properties:
        id: { type: string }
        job_id: { type: string }
        kind: { type: string, enum: [screenshot, pdf, download] }
        content_type: { type: string }
        object_key: { type: string, readOnly: true }
        size_bytes: { type: integer, format: int64, minimum: 0 }
        sha256: { type: string, pattern: '^[0-9a-f]{64}$' }
        created_at: { type: string, format: date-time }
    Job:
      type: object
      required: [id, project_id, status, input, created_at, updated_at, attempt_count, max_attempts, next_attempt_at]
      properties:
        id: { type: string }
        project_id: { type: string }
        session_id: { type: string }
        status: { type: string, enum: [queued, running, succeeded, failed, canceled] }
        input: { $ref: "#/components/schemas/CreateJob" }
        result: { type: object, additionalProperties: true }
        artifacts: { type: array, items: { $ref: "#/components/schemas/Artifact" } }
        error_code:
          type: string
          description: For example `execution_retry` while another attempt is pending and `execution_failed` once attempts are exhausted. `project_suspended` means the project was suspended while the job was queued, or its execution failed after the suspension; it is final and not retried.
        error_detail: { type: string }
        created_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        attempt_count: { type: integer, minimum: 0 }
        max_attempts: { type: integer, minimum: 1 }
        lease_expires_at: { type: string, format: date-time }
        next_attempt_at: { type: string, format: date-time }
    JobList:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Job" } }
    JobWebhookEvent:
      type: object
      required: [type, created_at, data]
      properties:
        type: { type: string, const: job.completed }
        created_at: { type: string, format: date-time }
        data: { $ref: "#/components/schemas/Job" }

    BillingState:
      type: object
      required: [organization_id, subscription_status, has_subscription, balance_micros, currency]
      properties:
        organization_id: { type: string }
        stripe_customer_id:
          type: string
          description: The organization's Stripe customer. Absent until one is linked; the organization's first subscription checkout links one. `POST /v1/billing/portal` requires it.
        subscription_status:
          type: string
          enum: [trialing, active, past_due, canceled, suspended]
          description: A new self-serve organization starts as `trialing` although it has no subscription, so use `has_subscription` to tell whether one exists. Once Stripe reports a subscription, this follows it. `suspended` means the subscription is unpaid, incomplete, or paused, and `canceled` means every subscription has ended. Unrelated to project suspension.
        has_subscription:
          type: boolean
          description: True while the organization has a Stripe subscription that can still bill (active, trialing, past due, unpaid, incomplete, or paused). The plan then changes on that subscription (see `POST /v1/billing/portal`), and checkout answers `409 subscription_exists`.
        subscription_plan:
          type: string
          example: growth
          description: The plan the subscription currently entitles, read from its current Stripe price. Omitted when no subscription grants a paid plan, including while it is unpaid, incomplete, or paused.
        balance_micros: { type: integer, format: int64 }
        currency: { type: string }
    CreateCheckout:
      type: object
      required: [plan]
      additionalProperties: false
      properties:
        plan:
          type: string
          enum: [developer, growth, enterprise]
          description: A plan whose `checkout_available` is true in `GET /v1/plans`. When billing is configured, any other plan, including one listed here that has no Stripe price on this deployment, is refused with `400 plan_unavailable`.
    CreateTopUp:
      type: object
      required: [amount_micros]
      additionalProperties: false
      properties:
        amount_micros: { type: integer, format: int64, minimum: 5000000, maximum: 10000000000, multipleOf: 10000 }
    HostedURL:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri }
    WebhookAccepted:
      type: object
      required: [received]
      properties:
        received: { type: boolean, const: true }

    Worker:
      type: object
      required: [id, name, region, status, capacity, hard_capacity, admission_limit, reserved_slots, running_slots, allocatable, allocated, warm_capacity, warm_available, booting, max_concurrent_boots, heartbeat_generation, last_heartbeat, created_at, updated_at, telemetry]
      properties:
        id: { type: string }
        name: { type: string }
        region: { type: string }
        endpoint: { type: string, format: uri }
        status: { type: string, enum: [online, draining, offline] }
        capacity: { type: integer, minimum: 0 }
        hard_capacity: { type: integer, minimum: 1 }
        admission_limit: { type: integer, minimum: 1 }
        reserved_slots: { type: integer, minimum: 0 }
        running_slots: { type: integer, minimum: 0 }
        labels: { type: object, additionalProperties: { type: string } }
        version: { type: string }
        image_version: { type: string }
        image_digest: { type: string }
        allocatable: { $ref: "#/components/schemas/Resources" }
        allocated: { $ref: "#/components/schemas/Resources" }
        warm_capacity: { type: integer, minimum: 0 }
        warm_available: { type: integer, minimum: 0 }
        booting: { type: integer, minimum: 0 }
        max_concurrent_boots: { type: integer, minimum: 0 }
        drain_deadline: { type: string, format: date-time }
        heartbeat_generation: { type: integer, format: int64, minimum: 0 }
        last_heartbeat: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        capacity_policy: { $ref: "#/components/schemas/WorkerCapacityPolicy" }
        telemetry: { $ref: "#/components/schemas/WorkerTelemetry" }
    WorkerList:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Worker" } }
    DrainWorker:
      type: object
      required: [draining]
      additionalProperties: false
      properties:
        draining: { type: boolean }
        deadline: { type: string, format: date-time, description: Optional future forced-maintenance deadline }
    WorkerCapacityPolicy:
      type: object
      additionalProperties: false
      required: [adaptive_enabled, resident_ceiling, warm_pool_target, min_active_sessions, max_active_sessions, cpu_target_percent, cpu_high_percent, cpu_psi_high_percent, scale_up_step, scale_down_step, clean_windows_required, cooldown_seconds]
      properties:
        worker_id: { type: string, readOnly: true }
        adaptive_enabled: { type: boolean }
        resident_ceiling: { type: integer, minimum: 1 }
        warm_pool_target: { type: integer, minimum: 0 }
        min_active_sessions: { type: integer, minimum: 1 }
        max_active_sessions: { type: integer, minimum: 1 }
        cpu_target_percent: { type: number, exclusiveMinimum: 0, exclusiveMaximum: 100 }
        cpu_high_percent: { type: number, exclusiveMinimum: 0, maximum: 100 }
        cpu_psi_high_percent: { type: number, exclusiveMinimum: 0, maximum: 100 }
        scale_up_step: { type: integer, minimum: 1 }
        scale_down_step: { type: integer, minimum: 1 }
        clean_windows_required: { type: integer, minimum: 1 }
        cooldown_seconds: { type: integer, minimum: 0 }
        revision: { type: integer, format: int64, readOnly: true }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }
    WorkerTelemetry:
      type: object
      readOnly: true
      properties:
        cpu_average_percent: { type: number }
        cpu_p95_percent: { type: number }
        cpu_pressure_p95_percent: { type: number }
        memory_available_mib: { type: integer, format: int64 }
        start_latency_p95_millis: { type: integer, format: int64 }
        start_failures: { type: integer }
        sample_count: { type: integer }
        window_seconds: { type: integer }
        observed_at: { type: string, format: date-time }

    ProxyEndpoint:
      type: object
      required: [id, provider, account, scheme, host, port, country, pool, max_concurrent, active_leases, monthly_cap_bytes, reserve_bytes, internal_bytes, provider_bytes, period_start, period_end, status, consecutive_failures, created_at, updated_at]
      properties:
        id: { type: string }
        provider: { type: string }
        account: { type: string }
        sim_id: { type: string }
        scheme: { type: string, enum: [http, socks5h] }
        host: { type: string }
        port: { type: integer, minimum: 1, maximum: 65535 }
        country: { type: string }
        region: { type: string }
        city: { type: string }
        timezone: { type: string }
        pool: { $ref: "#/components/schemas/ProxyPool" }
        max_concurrent: { type: integer, minimum: 1 }
        active_leases: { type: integer, minimum: 0 }
        monthly_cap_bytes: { type: integer, format: int64, minimum: 1 }
        reserve_bytes: { type: integer, format: int64, minimum: 0 }
        internal_bytes: { type: integer, format: int64, minimum: 0 }
        provider_bytes: { type: integer, format: int64, minimum: 0 }
        period_start: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
        status: { type: string, enum: [healthy, degraded, cooldown, exhausted, disabled] }
        consecutive_failures: { type: integer, minimum: 0 }
        cooldown_until: { type: string, format: date-time }
        last_check_at: { type: string, format: date-time }
        last_success_at: { type: string, format: date-time }
        last_failure: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ProxyEndpointList:
      type: object
      required: [data]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/ProxyEndpoint" } }
    ProxyInventoryInput:
      type: object
      additionalProperties: false
      required: [endpoint, country, timezone, pool, max_concurrent, monthly_cap_bytes, reserve_bytes]
      properties:
        endpoint:
          type: object
          additionalProperties: false
          required: [scheme, host, port, username, password]
          properties:
            id: { type: string, readOnly: true }
            scheme: { type: string, enum: [http, socks5h] }
            host: { type: string }
            port: { type: integer, minimum: 1, maximum: 65535 }
            username: { type: string, writeOnly: true }
            password: { type: string, writeOnly: true }
        provider: { type: string }
        account: { type: string }
        sim_id: { type: string }
        country: { type: string, minLength: 2, maxLength: 2 }
        region: { type: string }
        city: { type: string }
        timezone: { type: string }
        pool: { $ref: "#/components/schemas/ProxyPool" }
        max_concurrent: { type: integer, minimum: 1 }
        monthly_cap_bytes: { type: integer, format: int64, minimum: 1 }
        reserve_bytes: { type: integer, format: int64, minimum: 0 }
