openapi: 3.1.0
info:
  title: urlcap API
  version: "1.0.0"
  summary: HTTP requests, exactly the way you want them — a power tool for developers.
  description: >
    The versioned urlcap API. Currently exposes the "capture" tool (send an HTTP request to any URL
    and inspect the full parsed response) and a stateless TOTP code generator. All endpoints are served
    under https://urlcap.com/api/v1 and authenticated with an API key sent in the `X-API-Key` header.
  contact:
    name: urlcap support
    email: info@urlcap.com
servers:
  - url: https://urlcap.com/api/v1
    description: Production
tags:
  - name: Capture
    description: Send an HTTP request to a URL and return the parsed response.
  - name: Extract
    description: Run a navigation + information-retrieval job with a headless browser engine (asynchronous).
  - name: IP
    description: IPv4 / IPv6 address and CIDR-range tooling — membership checks and stored ranges.
  - name: TOTP
    description: Time-based one-time password generation.
  - name: Schedules
    description: Run a capture at a future time — once or on a cron schedule — and retrieve each run's stored result.
  - name: Datasets
    description: Named, de-duplicated collections of items of a single type (IP/CIDR or URL) — add, remove, replace and membership checks.
  - name: Bot detection
    description: Identify well-known web crawlers / AI bots by IP address. Backed by a daily-refreshed in-memory index of every CIDR each bot publishes.
  - name: DNS
    description: Reverse-DNS (PTR) and forward-DNS (A/AAAA) lookups, with optional FCrDNS forward-confirmation. TTL-cached in-process — each cache entry expires after the minimum TTL the upstream resolver returned (clamped to 30s..1h; negative answers cached 5 minutes).
  - name: Robots
    description: robots.txt retrieval, URL allow/deny checks, change-tracker watches with webhook notifications. Fetched bodies are TTL-cached in-process for 1 hour (1 minute on transport errors / 5xx). Watches require a per-user API key; a background job sweeps every 15 minutes and re-fetches each watch after its own per-watch frequency.
  - name: Web Bot Auth
    description: Verify a bot's RFC 9421 HTTP Message Signature (Ed25519) against the operator's published key directory. Implements the Web Bot Auth draft — the modern cryptographic alternative to relying on IP ranges and reverse DNS for "is this really Googlebot?"-style checks.
  - name: Sites
    description: Customer site management — declare hostnames, mint per-site ingest tokens, and read back the JA4 fingerprints urlcap has flagged as bots on each site (for edge blocklists). Authed with a urlcap API key.
  - name: Ingest
    description: Customer ingest channels — stream NDJSON request-event batches and asynchronous outcome verdicts (JS-challenge / auth / purchase / edge_action). **Auth differs from the rest of the API**, these endpoints accept a per-site bearer token issued by `POST /sites/{id}/ingest_keys`; the urlcap `X-API-Key` does not work here.
  - name: JA4
    description: JA4 TLS-fingerprint analytics — trailing-hour signals, long-window OS/agent/device classification, per-dimension profile breakdowns, and a 25-metric snapshot. Built on top of the request_events / aggregates ClickHouse cluster.
  - name: Monitors
    description: URL up/down monitors driven by the capture or extract engines. Polls a target every N seconds (down to 30 s on paid plans), runs the configured capture/extract spec, and fires HMAC-signed webhooks + email on state transitions. Same primitives as UptimeRobot plus full headless-browser checks, JSON-API validation, and per-monitor User-Agent personas.
security:
  - apiKeyHeader: []
paths:
  /capture:
    get:
      tags: [Capture]
      operationId: captureGet
      summary: Capture a request (quick fetch)
      description: >
        Sends a GET request to the target URL with sane default headers and returns the parsed response.
        For full control over method, headers (incl. their order) and body, use `POST /capture`.
        Note: a `200` here means urlcap reached the target — the target's own status is in `data.response.status`.
      parameters:
        - name: url
          in: query
          required: true
          description: The target URL (http or https). Always percent-encode it.
          schema: { type: string, example: "https://example.com/path?q=1" }
        - name: method
          in: query
          required: false
          schema: { type: string, enum: [GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH], default: GET }
        - name: followRedirects
          in: query
          required: false
          schema: { type: boolean, default: false }
        - name: timeoutMs
          in: query
          required: false
          schema: { type: integer, default: 10000, minimum: 1000, maximum: 30000 }
        - name: userAgent
          in: query
          required: false
          description: Explicit User-Agent override. See `CaptureSpec.userAgent`.
          schema: { type: string }
        - name: userAgentProfile
          in: query
          required: false
          description: Key into `user_agent_profiles`. See `GET /user_agent_profiles` for the catalogue.
          schema: { type: string, example: "chrome-latest-mac" }
      responses:
        "200": { description: The parsed capture., content: { application/json: { schema: { $ref: "#/components/schemas/CaptureResponse" } } } }
        "400": { description: Missing or invalid `url`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "502": { description: The target could not be reached., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Capture]
      operationId: capturePost
      summary: Capture a request (full control)
      description: >
        Sends a fully-specified HTTP request to the target URL. Headers are written to the wire in the
        exact order given. Only `url` is required.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CaptureSpec" }
      responses:
        "200": { description: The parsed capture., content: { application/json: { schema: { $ref: "#/components/schemas/CaptureResponse" } } } }
        "400": { description: Missing/invalid `url` or malformed JSON., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "502": { description: The target could not be reached., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /extract:
    post:
      tags: [Extract]
      operationId: submitExtractJob
      summary: Submit an extract job
      description: >
        Queues a navigation + information-retrieval job (run by a headless browser engine) and returns a
        task id. The job runs asynchronously — poll `GET /extract/{taskId}` for progress and the result.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExtractSpec" }
      responses:
        "202": { description: The job was accepted and queued., content: { application/json: { schema: { $ref: "#/components/schemas/ExtractAccepted" } } } }
        "400": { description: Missing/invalid model (no `url`, unparseable, non-http URL)., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "503": { description: Too many jobs queued — retry shortly., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    get:
      tags: [Extract]
      operationId: listExtractTasks
      summary: List recent extract tasks
      description: Your most recent extract tasks (newest first).
      responses:
        "200":
          description: Recent tasks.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: string, example: "1" }
                  tasks: { type: array, items: { $ref: "#/components/schemas/ExtractTaskSummary" } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /extract/{taskId}:
    get:
      tags: [Extract]
      operationId: getExtractTask
      summary: Get an extract task's status
      description: >
        Returns the task's state — `pending`, `running`, `succeeded`, or `failed`. When succeeded, a `result`
        object is included; when failed, an `error` object. You can only see your own tasks.
      parameters:
        - name: taskId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: The task., content: { application/json: { schema: { $ref: "#/components/schemas/ExtractTask" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such task (unknown id, or owned by another key)., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /ip/contains:
    get:
      tags: [IP]
      operationId: ipContainsGet
      summary: Is an address in a CIDR?
      description: A pure calculation — does `ip` fall within `cidr`? Different address families ⇒ false.
      parameters:
        - { name: ip, in: query, required: true, schema: { type: string, example: "10.20.30.40" } }
        - { name: cidr, in: query, required: true, schema: { type: string, example: "10.0.0.0/8" } }
      responses:
        "200": { description: The result., content: { application/json: { schema: { $ref: "#/components/schemas/IpContainsResponse" } } } }
        "400": { description: Missing/invalid `ip` or `cidr`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [IP]
      operationId: ipContainsPost
      summary: Is an address in one or more CIDRs?
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ip]
              properties:
                ip:    { type: string, example: "10.20.30.40" }
                cidr:  { type: string, example: "10.0.0.0/8" }
                cidrs: { type: array, items: { type: string }, example: ["10.0.0.0/8", "192.168.0.0/16", "2001:db8::/32"] }
      responses:
        "200": { description: The result (single → `contains`+`range`; batch → `results[]`)., content: { application/json: { schema: { $ref: "#/components/schemas/IpContainsResponse" } } } }
        "400": { description: Missing/invalid input., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /ip/lookup:
    get:
      tags: [IP]
      operationId: ipLookup
      summary: Which stored ranges contain an address?
      parameters:
        - { name: ip, in: query, required: true, schema: { type: string, example: "10.20.30.40" } }
      responses:
        "200": { description: Matching stored ranges, most-specific first., content: { application/json: { schema: { $ref: "#/components/schemas/IpLookupResponse" } } } }
        "400": { description: Missing/invalid `ip`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [IP]
      operationId: ipLookupPost
      summary: Which stored ranges contain an address?
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [ip], properties: { ip: { type: string } } }
      responses:
        "200": { description: Matching stored ranges., content: { application/json: { schema: { $ref: "#/components/schemas/IpLookupResponse" } } } }
        "400": { description: Missing/invalid `ip`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /ip/ranges:
    get:
      tags: [IP]
      operationId: listIpRanges
      summary: List stored IP / CIDR ranges
      responses:
        "200":
          description: Your stored ranges, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: string, example: "1" }
                  requestId: { type: string, format: uuid }
                  data:
                    type: object
                    properties:
                      ranges: { type: array, items: { $ref: "#/components/schemas/IpRange" } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [IP]
      operationId: addIpRange
      summary: Add (or relabel) a stored IP / CIDR range
      description: Adds the range; if its canonical CIDR is already stored, updates the label and returns the existing row.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cidr]
              properties:
                cidr:  { type: string, example: "10.20.0.0/16" }
                label: { type: string, example: "office-lan" }
      responses:
        "201": { description: The stored range., content: { application/json: { schema: { $ref: "#/components/schemas/IpRangeAdded" } } } }
        "400": { description: Missing/invalid `cidr`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /ip/ranges/{id}:
    delete:
      tags: [IP]
      operationId: deleteIpRange
      summary: Remove a stored IP / CIDR range
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: string, example: "1" }
                  data: { type: object, properties: { id: { type: integer }, deleted: { type: boolean } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such range., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /totp:
    get:
      tags: [TOTP]
      operationId: generateTotpGet
      summary: Generate a TOTP code
      description: >
        Computes the current TOTP code for the supplied `otpauth://` URI (the same string you'd
        scan into an authenticator app). The URI carries the shared secret and the
        algorithm/digits/period; nothing is stored server-side.
      parameters:
        - name: uri
          in: query
          required: true
          description: An `otpauth://totp/...` URI containing at least a Base32 `secret`. Always percent-encode this value.
          schema:
            type: string
            example: "otpauth://totp/Acme:alice@acme.io?secret=JBSWY3DPEHPK3PXP&period=30&digits=6"
      responses:
        "200": { description: The current code and its parameters., content: { application/json: { schema: { $ref: "#/components/schemas/TotpResponse" } } } }
        "400": { description: Missing or unparseable `uri`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [TOTP]
      operationId: generateTotpPost
      summary: Generate a TOTP code (form body)
      description: As `GET /totp`, but parameters are sent in an `application/x-www-form-urlencoded` body.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [uri]
              properties:
                uri:
                  type: string
                  example: "otpauth://totp/Acme:alice@acme.io?secret=JBSWY3DPEHPK3PXP&period=30&digits=6"
      responses:
        "200": { description: The current code and its parameters., content: { application/json: { schema: { $ref: "#/components/schemas/TotpResponse" } } } }
        "400": { description: Missing or unparseable `uri`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /schedules:
    get:
      tags: [Schedules]
      operationId: listSchedules
      summary: List your schedules
      responses:
        "200":
          description: Your scheduled tasks.
          content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { schedules: { type: array, items: { $ref: "#/components/schemas/Schedule" } } } } } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Schedules]
      operationId: createSchedule
      summary: Create a scheduled task (capture or extract)
      description: >-
        Provide either `cron` (recurring) or `runAt` (one-shot) — not both — plus exactly one of a
        `capture` object (same shape as the body of `POST /capture`) or an `extract` object (the extract
        job model). Which one you send determines the task `kind`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              oneOf:
                - required: [capture]
                - required: [extract]
              properties:
                name: { type: string, description: Optional label., example: prod health check }
                cron: { type: string, description: "Cron expression — classic 5-field crontab (an optional leading 6th field = seconds), or @hourly/@daily/@weekly/@monthly/@yearly. Mutually exclusive with runAt.", example: "*/15 * * * *" }
                runAt: { type: string, format: date-time, description: "ISO-8601 timestamp for a single run. Mutually exclusive with cron.", example: "2026-06-01T09:00:00Z" }
                timezone: { type: string, description: IANA zone the cron is evaluated in., default: UTC, example: Europe/Madrid }
                maxRuns: { type: integer, minimum: 1, description: Stop after this many executions. }
                until: { type: string, format: date-time, description: Stop after this time. }
                capture: { $ref: "#/components/schemas/CaptureSpec" }
                extract: { $ref: "#/components/schemas/ExtractSpec" }
      responses:
        "201":
          description: The created schedule.
          content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { schedule: { $ref: "#/components/schemas/Schedule" } } } } } } }
        "400": { description: Bad cron / timezone / missing or both task objects., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /schedules/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Schedules]
      operationId: getSchedule
      summary: Get one schedule
      responses:
        "200": { description: The schedule., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { schedule: { $ref: "#/components/schemas/Schedule" } } } } } } } }
        "404": { description: No such schedule., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Schedules]
      operationId: actOnSchedule
      summary: Pause, resume, or run-now
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties: { action: { type: string, enum: [pause, resume, run-now] } }
      responses:
        "200": { description: The updated schedule., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { schedule: { $ref: "#/components/schemas/Schedule" } } } } } } } }
        "400": { description: Unknown action., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such schedule., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    delete:
      tags: [Schedules]
      operationId: cancelSchedule
      summary: Cancel a schedule (run history is kept)
      responses:
        "200": { description: Cancelled., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { status: { type: string, example: disabled } } } } } } } }
        "404": { description: No such schedule., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /schedules/{id}/runs:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: limit, in: query, required: false, schema: { type: integer, default: 100 } }
    get:
      tags: [Schedules]
      operationId: listScheduleRuns
      summary: List a schedule's executions (newest first)
      responses:
        "200": { description: The runs., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { scheduleId: { type: string }, runs: { type: array, items: { $ref: "#/components/schemas/ScheduleRun" } } } } } } } } }
        "404": { description: No such schedule., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /schedules/{id}/runs/{runNo}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: runNo, in: path, required: true, schema: { type: integer } }
    get:
      tags: [Schedules]
      operationId: getScheduleRun
      summary: Get one execution, including its full result
      responses:
        "200": { description: The run plus its stored result (same shape `POST /capture` returns)., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { run: { $ref: "#/components/schemas/ScheduleRun" } } } } } } } }
        "404": { description: No such run., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /is_bot:
    get:
      tags: [Bot detection]
      operationId: isBotGet
      summary: Is an IP address a known bot?
      description: >-
        Checks one IP (`ip=…`) — or a small batch (`ips=a,b,c`) — against every CIDR in the
        per-bot internal datasets, returning whether each address belongs to a known crawler and,
        if so, which bot group, search engine and categories. Backed by an in-memory snapshot
        (preloaded; no DB hit per request) rebuilt after each daily refresh.


        The index also remembers CIDR ranges that have since been retired (replaced out by a later
        refresh). By default only the **currently-active** CIDRs are consulted, but `date=…` runs an
        as-of-date check, and `historical=true` widens the search to include every CIDR that has
        ever been published. Each match always reports its `addedAt`/`removedAt` lifespan plus an
        `active` flag relative to the query's time point.
      parameters:
        - { name: ip,  in: query, required: false, schema: { type: string, example: "66.249.66.1" } }
        - { name: ips, in: query, required: false, description: "Comma-separated, up to 200 addresses.", schema: { type: string, example: "66.249.66.1,8.8.8.8" } }
        - name: date
          in: query
          required: false
          description: >-
            ISO-8601 point-in-time. A bare `YYYY-MM-DD` is treated as that day's start in UTC.
            When supplied, the lookup returns the CIDR-bot mappings that were active at this
            moment — implicitly including retired records and ignoring `historical`.
          schema: { type: string, example: "2024-12-15" }
        - name: historical
          in: query
          required: false
          description: >-
            When `true`, the lookup also considers retired CIDR records (rows in
            `dataset_item_history` archived by a later refresh). Default `false` — only currently
            active CIDRs are consulted.
          schema: { type: boolean, default: false }
        - name: reverseDns
          in: query
          required: false
          description: >-
            When `true`, attach a `reverseDns` object to each per-IP result with the PTR names
            for the address. Results are cached in-process for the upstream record's TTL (clamped
            30s..1h; negative answers 5 minutes).
          schema: { type: boolean, default: false }
      responses:
        "200": { description: The result., content: { application/json: { schema: { $ref: "#/components/schemas/IsBotResponse" } } } }
        "400": { description: Missing input, bad `date`, or too many IPs., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Bot detection]
      operationId: isBotPost
      summary: Is an IP address a known bot? (batch form)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                ip:         { type: string,  description: "A single IP — equivalent to GET ?ip=…", example: "66.249.66.1" }
                ips:        { type: array, items: { type: string }, description: "A batch of IPs (max 200).", example: ["66.249.66.1", "::1"] }
                date:       { type: string, description: "ISO-8601 point-in-time. As-of-date check (includes retired CIDR records active at that moment).", example: "2024-12-15" }
                historical: { type: boolean, default: false, description: "Include retired CIDR records — every CIDR that has ever been in any bot's published list." }
                reverseDns: { type: boolean, default: false, description: "Attach a `reverseDns` object (PTR names + TTL info) to each per-IP result. TTL-cached in-process." }
      responses:
        "200": { description: The result., content: { application/json: { schema: { $ref: "#/components/schemas/IsBotResponse" } } } }
        "400": { description: Missing input, bad `date`, or too many IPs., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /reverse_dns:
    get:
      tags: [DNS]
      operationId: reverseDnsGet
      summary: PTR (reverse-DNS) lookup for one or more IPs
      description: >-
        Resolves an IPv4 or IPv6 address to the names returned by its PTR records (`in-addr.arpa`
        for v4, `ip6.arpa` for v6). Results are cached in-process for the minimum TTL the upstream
        resolver returned, clamped to 30s..1h; negative answers (NXDOMAIN / no record / error)
        are cached for 5 minutes. Each result reports `ttlSeconds` remaining and `cached`.


        With `forwardConfirm=true`, every PTR name is re-resolved (A + AAAA) and we report whether
        the original IP appears in the answer set — the FCrDNS check Googlebot, Bingbot et al.
        recommend to verify that a crawler is who its PTR claims it is.
      parameters:
        - { name: ip,  in: query, required: false, schema: { type: string, example: "66.249.66.1" } }
        - { name: ips, in: query, required: false, description: "Comma-separated, up to 50 addresses.", schema: { type: string, example: "66.249.66.1,8.8.8.8" } }
        - name: forwardConfirm
          in: query
          required: false
          description: >-
            When `true`, re-resolve each PTR name to A/AAAA and report whether the original IP is
            among the answers (`forwardConfirmed: true` if at least one name confirms back). Both
            the forward and reverse cache entries are TTL-aware.
          schema: { type: boolean, default: false }
      responses:
        "200": { description: The result., content: { application/json: { schema: { $ref: "#/components/schemas/ReverseDnsResponse" } } } }
        "400": { description: Missing input or too many IPs., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [DNS]
      operationId: reverseDnsPost
      summary: PTR (reverse-DNS) lookup — batch form
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                ip:             { type: string,  description: "A single IP — equivalent to GET ?ip=…", example: "66.249.66.1" }
                ips:            { type: array, items: { type: string }, description: "A batch of IPs (max 50).", example: ["66.249.66.1", "2001:4860:4860::8888"] }
                forwardConfirm: { type: boolean, default: false, description: "Re-resolve each PTR to A/AAAA and flag whether the original IP is in the answer (FCrDNS)." }
      responses:
        "200": { description: The result., content: { application/json: { schema: { $ref: "#/components/schemas/ReverseDnsResponse" } } } }
        "400": { description: Missing input or too many IPs., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /robots:
    get:
      tags: [Robots]
      operationId: robotsFetch
      summary: Fetch and parse a site's robots.txt
      description: >-
        Retrieves {@code /robots.txt} from {@code site} (hostname or URL — the scheme/path are
        normalised away), parses it per RFC 9309, and returns the groups, sitemaps and any
        unknown directives. Successful fetches are cached in-process for 1 hour; transport
        errors / 5xx are cached for 1 minute. {@code cached} and {@code ageSeconds} on every
        response let you see the staleness.


        Per RFC 9309, a 4xx (except 429) is reported with {@code effect: "no_rules_unrestricted"}
        and a 5xx or 429 with {@code effect: "restricted_by_error"}.
      parameters:
        - { name: site, in: query, required: true, schema: { type: string, example: "example.com" } }
      responses:
        "200": { description: The fetch + parse result., content: { application/json: { schema: { $ref: "#/components/schemas/RobotsFetchResponse" } } } }
        "400": { description: Missing or invalid 'site'., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /robots/check:
    get:
      tags: [Robots]
      operationId: robotsCheck
      summary: Is a URL allowed for a given user-agent?
      description: >-
        Fetches the site's robots.txt (TTL-cached) and applies RFC 9309 to decide whether
        {@code url} is allowed for {@code userAgent}. Longest-match wins; on a tie, Allow beats
        Disallow. If {@code site} is omitted it's derived from the URL.
      parameters:
        - { name: site,      in: query, required: false, schema: { type: string, example: "example.com" } }
        - { name: url,       in: query, required: true,  schema: { type: string, example: "https://example.com/private/foo" } }
        - { name: userAgent, in: query, required: true,  schema: { type: string, example: "Googlebot" } }
      responses:
        "200": { description: The decision., content: { application/json: { schema: { $ref: "#/components/schemas/RobotsCheckResponse" } } } }
        "400": { description: Missing 'url' or 'userAgent'., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /robots/watch:
    get:
      tags: [Robots]
      operationId: listRobotsWatches
      summary: List your robots.txt watches
      responses:
        "200": { description: Your watches, newest first., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { watches: { type: array, items: { $ref: "#/components/schemas/RobotsWatch" } } } } } } } } }
        "401": { description: Watches require a per-user API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Robots]
      operationId: createRobotsWatch
      summary: Create a watch — re-fetch periodically + webhook on change
      description: >-
        Registers a watch on {@code site}'s robots.txt. The background poller re-fetches each
        active watch (no sooner than its {@code frequencyMinutes}), writes a snapshot whenever
        the content hash changes, and — if {@code webhookUrl} is set — POSTs an HMAC-signed
        change notification. If no {@code webhookSecret} is supplied, one is generated and
        returned on this create response (it's omitted from subsequent reads).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [site]
              properties:
                site:             { type: string, description: "Hostname; scheme/path are stripped.", example: "example.com" }
                webhookUrl:       { type: string, description: "Where the change notification is POSTed. http:// or https://.", example: "https://hooks.example.com/robots" }
                webhookSecret:    { type: string, description: "HMAC-SHA256 signing key. Auto-generated if webhookUrl is set and this is omitted." }
                frequencyMinutes: { type: integer, description: "Minimum interval between fetches for this watch. Clamped 15..1440. Default 60.", default: 60, minimum: 15, maximum: 1440 }
      responses:
        "201": { description: The created watch (full object, with webhookSecret if generated)., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { watch: { $ref: "#/components/schemas/RobotsWatch" } } } } } } } }
        "400": { description: Invalid 'site' / 'webhookUrl', or you already watch that site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Watches require a per-user API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /robots/watch/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Robots]
      operationId: getRobotsWatch
      summary: Get one watch (includes webhookSecret)
      responses:
        "200": { description: The watch., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { watch: { $ref: "#/components/schemas/RobotsWatch" } } } } } } } }
        "404": { description: No such watch., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    delete:
      tags: [Robots]
      operationId: deleteRobotsWatch
      summary: Remove a watch (deletes all its snapshots)
      responses:
        "200": { description: Deleted., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { id: { type: string, format: uuid }, deleted: { type: boolean } } } } } } } }
        "404": { description: No such watch., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /robots/watch/{id}/history:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: limit, in: query, required: false, schema: { type: integer, default: 50, minimum: 1, maximum: 500 } }
      - { name: changedOnly, in: query, required: false, description: "Only return snapshots where the content differed from the previous one (default).", schema: { type: boolean, default: true } }
      - { name: includeBody, in: query, required: false, description: "Include the full robots.txt body on every snapshot. Default false.", schema: { type: boolean, default: false } }
    get:
      tags: [Robots]
      operationId: robotsWatchHistory
      summary: List a watch's snapshots over time
      responses:
        "200":
          description: A page of snapshots, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:   { type: string }
                  requestId: { type: string }
                  data:
                    type: object
                    properties:
                      watchId:     { type: string, format: uuid }
                      changedOnly: { type: boolean }
                      snapshots:   { type: array, items: { $ref: "#/components/schemas/RobotsSnapshot" } }
        "404": { description: No such watch., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /robots/watch/{id}/poll:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Robots]
      operationId: robotsWatchManualPoll
      summary: Force an immediate poll (bypassing the per-watch frequency throttle)
      description: >-
        Runs one fetch-diff-snapshot-(webhook) cycle right now. Useful for "did my change land?"
        smoke tests and to seed the first snapshot of a fresh watch without waiting for the
        scheduled sweep.
      responses:
        "200":
          description: Outcome of the manual poll.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:   { type: string }
                  requestId: { type: string }
                  data:
                    type: object
                    properties:
                      watchId:          { type: string, format: uuid }
                      changed:          { type: boolean }
                      webhookDelivered: { type: boolean }
                      watch:            { $ref: "#/components/schemas/RobotsWatch" }
        "404": { description: No such watch., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "502": { description: The fetch itself failed., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /web_bot_auth/verify:
    post:
      tags: [Web Bot Auth]
      operationId: verifyWebBotAuth
      summary: Verify a Web Bot Auth signature on an inbound request
      description: >-
        Given the HTTP method + URL of an inbound request and the {@code Signature-Input},
        {@code Signature} and {@code Signature-Agent} headers the bot supplied, decide whether
        the signature is valid against the operator's published Ed25519 key directory.


        Implements the Web Bot Auth draft on top of RFC 9421 HTTP Message Signatures. The
        verifier:
        (1) parses the structured-field headers;
        (2) fetches the directory at {@code Signature-Agent} (TTL-cached in-process for 1 hour,
            1 minute on errors);
        (3) reconstructs the signature base from the covered components and request details;
        (4) verifies the signature with Ed25519 using the directory key whose {@code kid}
            matches the {@code keyid} parameter.


        Failure modes (expired signature, missing keyid, directory unreachable, signature mismatch,
        unsupported algorithm, missing {@code tag="web-bot-auth"}) are returned as definitive
        {@code verified:false} answers with a {@code reason} string — not HTTP errors — so policy
        decisions don't need a try/catch.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [method, url, headers]
              properties:
                method:               { type: string, example: "GET" }
                url:                  { type: string, example: "https://example.com/foo" }
                headers:
                  type: object
                  description: >-
                    The inbound headers, as a name → value map. At minimum must include
                    {@code Signature}, {@code Signature-Input}, and {@code Signature-Agent}.
                    Any header named in the signature's covered-components list must also be
                    present here.
                  additionalProperties: { type: string }
                label:                { type: string, description: "Pick a specific entry from a multi-signature dictionary. Default: first." }
                allowExpired:         { type: boolean, default: false, description: "Don't reject signatures whose `expires` is in the past." }
                requireWebBotAuthTag: { type: boolean, default: true, description: "Require Signature-Input to carry tag=\"web-bot-auth\" (the draft MUST)." }
      responses:
        "200": { description: A definitive verdict (the verifier itself never fails-as-HTTP)., content: { application/json: { schema: { $ref: "#/components/schemas/WebBotAuthResponse" } } } }
        "400": { description: Malformed request body — missing method/url/headers., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /user_agent_profiles:
    get:
      tags: [Capture]
      operationId: listUserAgentProfiles
      summary: List the available User-Agent / browser-engine personas
      description: >-
        The operator-managed catalogue keyed by `profile_key`. Use the keys returned here as the
        `userAgentProfile` field on `/capture` and `/extract`. Each row carries the UA string, the
        declared JS engine (CHROME / FIREFOX / FIREFOX_ESR / EDGE — relevant to `/extract` only),
        and an `extraHeaders` map of headers the profile injects (Accept, Accept-Language,
        sec-ch-ua-*). Seeded with chrome-/firefox-/edge-/safari-latest-{win,mac,linux},
        `googlebot`, and `urlcap`; operators may add / disable rows in `user_agent_profiles`.
      responses:
        "200": { description: The active catalogue., content: { application/json: { schema: { $ref: "#/components/schemas/UserAgentProfilesResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /datasets:
    get:
      tags: [Datasets]
      operationId: listDatasets
      summary: List your datasets
      responses:
        "200": { description: Your datasets, newest first., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { datasets: { type: array, items: { $ref: "#/components/schemas/Dataset" } } } } } } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Datasets]
      operationId: createDataset
      summary: Create a dataset
      description: >-
        Creates a dataset of the given `type`. If `name` is omitted, a random name beginning `dataset-` is
        assigned. Optionally seed it with `items`. Fails (400) if the name is already taken, you're at your
        plan's dataset limit, the seed items overflow the per-dataset size limit, or `history` is requested
        on a plan that doesn't allow historical datasets.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                name:    { type: string, description: "Unique among your datasets; auto-assigned 'dataset-…' if omitted.", example: blocked-ranges }
                type:    { type: string, enum: [ip, url], description: "Item type — 'ip' = IP/CIDR (canonical CIDRs, as in /ip/ranges), 'url' = absolute http(s) URLs." }
                history: { type: boolean, default: false, description: "Keep removed items (with their removal date) when a replace drops them. Requires a plan that allows historical datasets." }
                items:   { type: array, items: { type: string }, description: "Optional initial items.", example: ["10.0.0.0/8", "192.168.1.1"] }
      responses:
        "201": { description: The created dataset (`data.dataset.size` = items stored)., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { dataset: { $ref: "#/components/schemas/Dataset" } } } } } } } }
        "400": { description: Unknown type, duplicate name, plan limit reached, or an invalid seed item., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /datasets/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Datasets]
      operationId: getDataset
      summary: Get one dataset
      responses:
        "200": { description: The dataset, including its current item count (`data.dataset.size`)., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { dataset: { $ref: "#/components/schemas/Dataset" } } } } } } } }
        "404": { description: No such dataset., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    delete:
      tags: [Datasets]
      operationId: deleteDataset
      summary: Delete a dataset (its items and history go too)
      responses:
        "200": { description: Deleted., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { id: { type: string, format: uuid }, deleted: { type: boolean } } } } } } } }
        "404": { description: No such dataset., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /datasets/{id}/items:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Datasets]
      operationId: listDatasetItems
      summary: List a dataset's items
      description: Items ordered by id. Page with `after` = the `nextAfter` from the previous response (0/omitted for the first page).
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, default: 1000, minimum: 1, maximum: 5000 } }
        - { name: after, in: query, required: false, schema: { type: integer, format: int64, default: 0 } }
      responses:
        "200":
          description: A page of items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:   { type: string }
                  requestId: { type: string }
                  data:
                    type: object
                    properties:
                      datasetId: { type: string, format: uuid }
                      items:     { type: array, items: { $ref: "#/components/schemas/DatasetItem" } }
                      count:     { type: integer }
                      nextAfter: { type: [integer, "null"], format: int64, description: "Pass as `after` for the next page; null when there were no items." }
        "404": { description: No such dataset., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Datasets]
      operationId: addDatasetItems
      summary: Add items to a dataset
      description: Adds items, de-duplicated against what's already there. Fails (400) if it would exceed the dataset's size limit or an item isn't valid for the dataset's type.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DatasetItemsBody" }
      responses:
        "200": { description: "{ added, size }.", content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { datasetId: { type: string, format: uuid }, added: { type: integer }, size: { type: integer, format: int64 } } } } } } } }
        "400": { description: Invalid item, or the dataset's size limit would be exceeded., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such dataset., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    put:
      tags: [Datasets]
      operationId: replaceDatasetItems
      summary: Replace a dataset's entire contents
      description: Replaces every item with the supplied ones. On a `history=true` dataset, the items being dropped are first kept in the dataset's history with their removal date.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DatasetItemsBody" }
      responses:
        "200": { description: "{ replaced, history, size }.", content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { datasetId: { type: string, format: uuid }, replaced: { type: boolean }, history: { type: boolean }, size: { type: integer, format: int64 } } } } } } } }
        "400": { description: Invalid item, or the dataset's size limit would be exceeded., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such dataset., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    delete:
      tags: [Datasets]
      operationId: removeDatasetItems
      summary: Remove items from a dataset
      description: Removes the listed items; ones that aren't present are ignored.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DatasetItemsBody" }
      responses:
        "200": { description: "{ removed, size }.", content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { datasetId: { type: string, format: uuid }, removed: { type: integer }, size: { type: integer, format: int64 } } } } } } } }
        "400": { description: An item isn't valid for the dataset's type., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such dataset., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /datasets/{id}/history:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: limit, in: query, required: false, schema: { type: integer, default: 1000, minimum: 1, maximum: 5000 } }
    get:
      tags: [Datasets]
      operationId: listDatasetHistory
      summary: List a dataset's removed items (history datasets)
      description: For `history=true` datasets — the items dropped by replaces, newest removal first. Always empty for non-history datasets.
      responses:
        "200":
          description: Historic items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:   { type: string }
                  requestId: { type: string }
                  data:
                    type: object
                    properties:
                      datasetId: { type: string, format: uuid }
                      history:   { type: array, items: { $ref: "#/components/schemas/DatasetHistoryItem" } }
                      count:     { type: integer }
        "404": { description: No such dataset., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /datasets/{id}/contains:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Datasets]
      operationId: datasetContainsGet
      summary: Is an item in the dataset?
      description: >-
        Normalises `value` to the dataset's type and reports whether that exact item is in the dataset
        (`contains`). For IP/CIDR datasets, when `value` is a single address it also reports `covered` —
        whether that address falls inside some CIDR stored in the dataset.
      parameters:
        - { name: value, in: query, required: true, schema: { type: string, example: "10.20.30.40" } }
      responses:
        "200": { description: The result., content: { application/json: { schema: { $ref: "#/components/schemas/DatasetContainsResponse" } } } }
        "400": { description: Missing `value`, or it isn't valid for the dataset's type., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such dataset., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Datasets]
      operationId: datasetContainsPost
      summary: Is an item in the dataset?
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [value], properties: { value: { type: string, example: "https://example.com/x" } } }
      responses:
        "200": { description: The result., content: { application/json: { schema: { $ref: "#/components/schemas/DatasetContainsResponse" } } } }
        "400": { description: Missing `value`, or it isn't valid for the dataset's type., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such dataset., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /sites:
    get:
      tags: [Sites]
      operationId: listSites
      summary: List the caller's sites
      description: Sites owned by the API key's user. Returns the site id, public id, and display name.
      responses:
        "200": { description: The caller's sites., content: { application/json: { schema: { $ref: "#/components/schemas/SitesListResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Sites]
      operationId: createSite
      summary: Create a site
      description: A site groups hostnames + ingest tokens. Create one per customer-facing property.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [name], properties: { name: { type: string, example: "example.com (prod)" } } }
      responses:
        "201": { description: The site., content: { application/json: { schema: { $ref: "#/components/schemas/SiteResponse" } } } }
        "400": { description: Missing or invalid `name`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /sites/{id}/domains:
    parameters:
      - { name: id, in: path, required: true, description: "Numeric site id OR 12-char base62 public id.", schema: { type: string, example: "2DrxGfsYW0jv" } }
    get:
      tags: [Sites]
      operationId: listSiteDomains
      summary: List a site's domains
      description: Hostnames currently attached to the site. Hostnames are globally UNIQUE — a hostname belongs to exactly one site.
      responses:
        "200": { description: Attached domains., content: { application/json: { schema: { $ref: "#/components/schemas/SiteDomainsListResponse" } } } }
        "404": { description: No such site (or not owned by the caller)., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Sites]
      operationId: addSiteDomain
      summary: Attach a hostname to a site
      description: Hostnames must be unique across all sites. `kind` is informational (default `doc`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hostname]
              properties:
                hostname: { type: string, example: "www.example.com" }
                kind:     { type: string, enum: [doc, asset, api], default: doc, description: "Hint at the traffic class served from this hostname. Default `doc`." }
      responses:
        "201": { description: The new domain row., content: { application/json: { schema: { $ref: "#/components/schemas/SiteDomainResponse" } } } }
        "400": { description: Missing or invalid `hostname`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "409": { description: Hostname is already attached to a site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /sites/{id}/ingest_keys:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, example: "2DrxGfsYW0jv" } }
    get:
      tags: [Sites]
      operationId: listIngestKeys
      summary: List a site's ingest tokens
      description: >
        Metadata only — the cleartext token is returned **once**, when minted via POST. List shows
        id, label, created/last-used/revoked timestamps; revoked keys still appear so the audit
        history is complete.
      responses:
        "200": { description: Ingest keys for the site., content: { application/json: { schema: { $ref: "#/components/schemas/IngestKeysListResponse" } } } }
        "404": { description: No such site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Sites]
      operationId: issueIngestKey
      summary: Mint a new ingest token
      description: >
        Issues a fresh ingest token scoped to this site. The token is in the response under
        `data.token` — **save it immediately**, urlcap only stores the SHA-256 hash and the
        cleartext value cannot be retrieved later. Use the token as
        `Authorization: Bearer ingest_…` on `/ingest/{site}/events` and `/ingest/{site}/outcomes`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, description: "Free-form name to identify the key later (e.g. `vector-prod-eu`).", example: "vector-prod-eu" }
      responses:
        "201": { description: The minted token (returned exactly once)., content: { application/json: { schema: { $ref: "#/components/schemas/IngestKeyIssuedResponse" } } } }
        "404": { description: No such site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /sites/{id}/bot-ja4s:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, example: "2DrxGfsYW0jv" } }
      - name: window_days
        in: query
        required: false
        description: Sliding window for the JA4 enumeration. Bounded by the `request_events` 7-day raw-event TTL on the high end (data beyond is gone).
        schema: { type: integer, default: 7, minimum: 1, maximum: 90 }
      - name: include
        in: query
        required: false
        description: Comma-separated subset of `known,candidate`. Browser-shaped JA4s are always filtered out.
        schema: { type: string, default: "known,candidate", example: "known" }
      - name: limit
        in: query
        required: false
        schema: { type: integer, default: 200, minimum: 1, maximum: 1000 }
    get:
      tags: [Sites]
      operationId: listBotJa4s
      summary: List JA4 fingerprints flagged as bots on this site
      description: >
        The customer-facing blocklist feed. Returns every JA4 the bot-discovery system has classified
        for traffic touching this site over the window, with per-row attribution (known bot group or
        discovery candidate), rate metrics (peak/mean RPM, burstiness), inferred-block hints from the
        site's own status-code mix, and cross-customer `edge_action` votes summed from
        `/ingest/{site}/outcomes`. Feed the `ja4` strings into your edge blocklist.
      responses:
        "200": { description: The JA4 list., content: { application/json: { schema: { $ref: "#/components/schemas/BotJa4sResponse" } } } }
        "404": { description: No such site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /sites/{id}/bot-ip-candidates:
    parameters:
      - { name: id,          in: path,  required: true, schema: { type: string, example: "2DrxGfsYW0jv" } }
      - { name: window_days, in: query, required: false, schema: { type: integer, default: 7, minimum: 1, maximum: 30 } }
      - { name: min_score,   in: query, required: false, description: "Score floor (0..1). Default 0.50; raise to ~0.70 for only the most obvious abusers, lower to ~0.30 for a wider net.", schema: { type: number, default: 0.50, minimum: 0, maximum: 1 } }
      - { name: limit,       in: query, required: false, schema: { type: integer, default: 200, minimum: 1, maximum: 1000 } }
      - { name: format,      in: query, required: false, description: "`json` (default) returns the full operator shape; `txt` returns one IP per line; `cidr` returns each IP as /32 (or /128) per line — both text formats use text/plain content-type for easy ipset/iptables ingest.", schema: { type: string, enum: [json, txt, cidr], default: json } }
    get:
      tags: [Sites]
      operationId: getSiteBotIpCandidates
      summary: Candidate IPs — abusive under multiple signals, not yet attributed
      description: >-
        Per-site live feed of IPs exhibiting abusive behaviour under five composite signals.
        The IP equivalent of the JA4 `bot_candidates` queue — small list by construction (score
        floor), refreshes every call.
        **Score components** (weights sum to 1.0): 40% block_ratio (4xx/444 share),
        20% volume (log-scaled, saturates at ~10k reqs), 15% path breadth (saturates at 100 paths),
        15% JA4 churn (3+ JA4s from one IP), 10% vuln-probe hits (saturates at 3).
        **Hard exclusions** (row dropped before scoring): trust-list IPs · IPs already classified
        in `bot_observed_ips` (no point re-discovering known bots) · `reqs < 20` (insufficient evidence).
      responses:
        "200":
          description: Candidate-IP feed. JSON format returns a structured envelope; txt / cidr return text/plain bodies.
          content:
            application/json: { schema: { $ref: "#/components/schemas/SiteBotIpCandidatesResponse" } }
            text/plain:       { schema: { type: string, example: "203.0.113.5\n198.51.100.42\n…" } }
        "404": { description: No such site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /sites/{id}/url-stats:
    parameters:
      - { name: id,    in: path,  required: true, schema: { type: string, example: "2DrxGfsYW0jv" } }
      - { name: host,  in: query, required: true, description: "The hostname to inspect (must already be attached to the site).", schema: { type: string, example: "en.example.com" } }
      - { name: path,  in: query, required: true, description: "The exact request path (case-sensitive, with leading slash).",     schema: { type: string, example: "/terms-of-service.html" } }
      - { name: days,  in: query, required: false, schema: { type: integer, default: 7, minimum: 1, maximum: 30 } }
    get:
      tags: [Sites]
      operationId: getSiteUrlStats
      summary: Quick "is this URL healthy?" report
      description: >-
        Traffic + rejections summary for a specific URL over a sliding window. Single call
        returns totals, status-code breakdown, per-day chart, and up to 25 most recent non-2xx
        requests with their IP / ASN / JA4 — the same data we'd otherwise hand-write
        ClickHouse queries for. Use to answer "any rejections on URL X today?" in one shot.
      responses:
        "200": { description: The stats., content: { application/json: { schema: { $ref: "#/components/schemas/SiteUrlStatsResponse" } } } }
        "400": { description: Missing host or path., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /sites/{id}/bot-traffic:
    parameters:
      - { name: id,        in: path,  required: true, schema: { type: string, example: "2DrxGfsYW0jv" } }
      - { name: bot_group, in: query, required: true, description: "Substring-match against `bot_groups.description`. 'Google' catches Googlebot + 'User-triggered fetchers (Google)'. Pass the exact name for a single bot group.", schema: { type: string, example: "Googlebot" } }
      - { name: host,      in: query, required: false, schema: { type: string } }
      - { name: path,      in: query, required: false, schema: { type: string } }
      - { name: days,      in: query, required: false, schema: { type: integer, default: 1, minimum: 1, maximum: 30 } }
    get:
      tags: [Sites]
      operationId: getSiteBotTraffic
      summary: Per-bot accessibility check
      description: >-
        Did urlcap-discovered bot traffic land successfully on this site? Given a `bot_group`
        name, returns the bot's recent visits, status breakdown, per-day chart, and a sample
        of recent requests (non-2xx surfaced first) so blockages are obvious at a glance.
        Bot identification matches against every JA4 the discovery system has attributed to the
        bot_group via `bot_observed_ja4s` — catches both CIDR- and UA-matched traffic without
        enumerating IP lists.
      responses:
        "200": { description: The bot's traffic on this site., content: { application/json: { schema: { $ref: "#/components/schemas/SiteBotTrafficResponse" } } } }
        "400": { description: Missing `bot_group`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such site, or no bot_group matches the filter., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /ingest/{site}/events:
    parameters:
      - { name: site, in: path, required: true, description: "Numeric site id OR 12-char base62 public id.", schema: { type: string, example: "2DrxGfsYW0jv" } }
    post:
      tags: [Ingest]
      operationId: ingestEvents
      summary: Ingest a batch of request events (NDJSON)
      description: >
        Stream a newline-delimited JSON batch of request events. Each line is one event; mandatory
        fields are `ts`, `ja4`, `ja4_hash`, `ip`, `host`, `path` (see `IngestEvent`). Every `host`
        in the batch must already be attached to the site via `/sites/{id}/domains` — events from
        unattached hostnames are rejected per row. Caps: **1000 events / 4 MB body** per batch.
        Partial failures never fail the batch — per-row errors are returned in `data.errors[]`.

        **Authentication**: per-site bearer token (`Authorization: Bearer ingest_…`), *not* the
        urlcap API key. Mint one with `POST /sites/{id}/ingest_keys`.
      security:
        - ingestBearer: []
      requestBody:
        required: true
        content:
          application/x-ndjson:
            schema:
              type: string
              description: One JSON object per line; see `IngestEvent` schema for the per-line shape.
              example: |
                {"ts":"2026-05-25T10:00:00Z","ja4":"t13d1516h2_8daaf6152771_b1ff8ab2d16f","ja4_hash":13366807129412944815,"ip":"203.0.113.5","host":"www.example.com","path":"/","method":"GET","status":200,"http_version":"h2","ua":"Mozilla/5.0 (...) Firefox/137.0","request_id":"abc123"}
                {"ts":"2026-05-25T10:00:00.050Z","ja4":"t13d1516h2_8daaf6152771_b1ff8ab2d16f","ja4_hash":13366807129412944815,"ip":"203.0.113.5","host":"www.example.com","path":"/assets/main.css","status":200,"request_id":"def456"}
      responses:
        "202":
          description: Batch accepted (some rows may have been rejected — see `data.rejected` + `data.errors`).
          content: { application/json: { schema: { $ref: "#/components/schemas/IngestAccepted" } } }
        "400": { description: Empty batch / invalid NDJSON., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing/invalid bearer token, or token doesn't match the site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "413": { description: Body exceeds 4 MB or > 1000 events., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /ingest/{site}/outcomes:
    parameters:
      - { name: site, in: path, required: true, schema: { type: string, example: "2DrxGfsYW0jv" } }
    post:
      tags: [Ingest]
      operationId: ingestOutcomes
      summary: Ingest asynchronous outcome verdicts (NDJSON)
      description: >
        Asynchronous verdict channel — once the user later passes a JS challenge / signs in /
        completes a purchase, or once your edge takes an `edge_action`, send the verdict here keyed
        by the original event's `request_id`. urlcap resolves the JA4 (and denormalises it onto the
        outcome row so it survives the 7-day raw-event TTL). Kinds and verdicts:

        | kind         | verdicts                          |
        | ------------ | --------------------------------- |
        | js_challenge | passed, failed, abandoned         |
        | auth         | authenticated, signup, anonymous  |
        | purchase     | completed, refunded, disputed     |
        | edge_action  | blocked, allowed, challenged      |

        `meta` is a free-form JSON object (or stringified JSON) — fields like `user_id_hash`,
        `order_id`, `amount_cents` are extracted by downstream rollups via CH `JSONExtract*`.
      security:
        - ingestBearer: []
      requestBody:
        required: true
        content:
          application/x-ndjson:
            schema:
              type: string
              description: One outcome per line; see `IngestOutcome` schema.
              example: |
                {"ts":"2026-05-25T10:00:01Z","request_id":"abc123","kind":"js_challenge","verdict":"passed","score":0.92}
                {"ts":"2026-05-25T10:00:02Z","request_id":"abc123","kind":"auth","verdict":"authenticated","meta":{"user_id_hash":"a3f9c…"}}
                {"ts":"2026-05-25T10:01:00Z","request_id":"def456","kind":"edge_action","verdict":"blocked"}
      responses:
        "202": { description: Batch accepted., content: { application/json: { schema: { $ref: "#/components/schemas/IngestAccepted" } } } }
        "400": { description: Empty batch / invalid NDJSON., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing/invalid bearer token, or token doesn't match the site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such site., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "413": { description: Body exceeds 4 MB or per-batch outcome cap., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /ja4/signals:
    get:
      tags: [JA4]
      operationId: getJa4Signals
      summary: Trailing-hour Cloudflare-style signals for one JA4
      description: >
        Cloudflare-style "what does this JA4 look like right now?" snapshot. Reads from
        `ja4_signals_latest`, rewritten every minute by the `ja4-signals-compute` job. 10 ratios +
        4 ranks + 2 quantiles per JA4 per site. Returns 404 if the JA4 hasn't been seen on this site
        within the trailing hour.
      parameters:
        - { name: site_id,  in: query, required: true, schema: { type: integer, format: int64 } }
        - { name: ja4_hash, in: query, required: true, description: "JA4 hash as an unsigned 64-bit decimal string.", schema: { type: string, example: "13366807129412944815" } }
      responses:
        "200": { description: The trailing-hour signal row., content: { application/json: { schema: { $ref: "#/components/schemas/Ja4SignalsResponse" } } } }
        "400": { description: Missing or invalid `site_id` / `ja4_hash`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No signals for this JA4 in the trailing hour., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "503": { description: ClickHouse not ready., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /ja4/intelligence:
    get:
      tags: [JA4]
      operationId: getJa4Intelligence
      summary: Long-window OS/agent/device classification for one JA4
      description: >
        The "who/what is behind this JA4?" lookup. Reads from `ja4_intelligence_latest`, populated
        hourly by the `ja4-intelligence-compute` job over 7 / 30 / 90 day windows. Returns the
        classifier's top-OS / top-agent / top-device choice with per-dimension confidence (the score
        IS the confidence — apply your own threshold), diversity scores, and outcome-derived counts
        (JS challenge attempts/passes, auth observations, distinct users, purchases). Also returns
        `ja4_ua_consistency` (UA-spoofing tell — lower = the JA4 claims mismatched UAs).
      parameters:
        - { name: site_id,  in: query, required: true, schema: { type: integer, format: int64 } }
        - { name: ja4_hash, in: query, required: true, schema: { type: string, example: "13366807129412944815" } }
        - { name: window,   in: query, required: false, description: "Window length in days; whatever the compute job currently emits (default config `7,30,90`).", schema: { type: integer, default: 90, enum: [7, 30, 90] } }
      responses:
        "200": { description: The classifier row., content: { application/json: { schema: { $ref: "#/components/schemas/Ja4IntelligenceResponse" } } } }
        "400": { description: Missing or invalid params., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No row for this (site, ja4, window)., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "503": { description: ClickHouse not ready., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /ja4/metrics:
    get:
      tags: [JA4]
      operationId: getJa4Metrics
      summary: 25-metric snapshot for one JA4 (optionally narrowed to an IP)
      description: >
        On-demand "prioritised 25" snapshot — computed from `ja4_agg_5m`, `ip_ja4_agg_1h`, and
        `ja4_ua_pair_1d` (no precompute job). Three blocks:
        * `ja4_1h` — trailing-hour rollup (12 × 5-min buckets). Req count, distinct ips/uas/paths/hosts, h2h3/browser/error/403/404/429 ratios.
        * `ip_ja4_1h` — present only when `ip` is supplied. Same shape narrowed to that IP, plus `library_ua_ratio`, `is_new_24h`.
        * `ja4_ua_24h_top` — top-10 UA hashes seen with this JA4 over 24h, with `share_of_ja4`.
      parameters:
        - { name: site_id,  in: query, required: true, schema: { type: integer, format: int64 } }
        - { name: ja4_hash, in: query, required: true, schema: { type: string, example: "13366807129412944815" } }
        - { name: ip,       in: query, required: false, description: "Narrow the snapshot to one IP. Adds the `ip_ja4_1h` block to the response.", schema: { type: string, example: "203.0.113.5" } }
      responses:
        "200": { description: The 25-metric snapshot., content: { application/json: { schema: { $ref: "#/components/schemas/Ja4MetricsResponse" } } } }
        "400": { description: Missing or invalid params., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "503": { description: ClickHouse not ready., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /ja4/profile:
    get:
      tags: [JA4]
      operationId: getJa4Profile
      summary: Top-N values of one profile dimension for a JA4
      description: >
        Drill into one dimension of `ja4_profile_1d`. Sums each (dim_name, dim_value) over the
        requested window and returns top-N by request count, with HLL-merged distinct ips/uas/paths.
        Whitelisted dims:
        `os_name`, `os_class`, `agent_name`, `agent_class`, `device_class`, `device_brand`,
        `country`, `asn`, `http_version`.
      parameters:
        - { name: site_id,  in: query, required: true, schema: { type: integer, format: int64 } }
        - { name: ja4_hash, in: query, required: true, schema: { type: string, example: "13366807129412944815" } }
        - { name: dim,      in: query, required: true, schema: { type: string, enum: [os_name, os_class, agent_name, agent_class, device_class, device_brand, country, asn, http_version] } }
        - { name: days,     in: query, required: false, schema: { type: integer, default: 90, minimum: 1, maximum: 730 } }
        - { name: limit,    in: query, required: false, schema: { type: integer, default: 20, minimum: 1, maximum: 500 } }
      responses:
        "200": { description: Top-N values., content: { application/json: { schema: { $ref: "#/components/schemas/Ja4ProfileResponse" } } } }
        "400": { description: Missing/invalid params or unknown `dim`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "503": { description: ClickHouse not ready., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /ip/profile:
    get:
      tags: [IP]
      operationId: getIpProfile
      summary: Per-IP rollup over the last N days (one site)
      description: >
        Per-IP behavioural summary from `ip_profile_1d` on a single site — useful for spotting
        proxy-like IPs (many distinct JA4s / UAs / paths / hosts from one source). Returns 404 when
        the IP hasn't sent traffic to the site within the window.
      parameters:
        - { name: site_id, in: query, required: true, schema: { type: integer, format: int64 } }
        - { name: ip,      in: query, required: true, schema: { type: string, example: "203.0.113.5" } }
        - { name: days,    in: query, required: false, schema: { type: integer, default: 30, minimum: 1, maximum: 730 } }
      responses:
        "200": { description: The IP's rollup., content: { application/json: { schema: { $ref: "#/components/schemas/IpProfileResponse" } } } }
        "400": { description: Missing or invalid params., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No traffic from this IP in the window., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "503": { description: ClickHouse not ready., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /ip/intelligence:
    get:
      tags: [IP]
      operationId: getIpIntelligenceGet
      summary: Cross-site single-IP investigation
      description: >
        Composes every signal urlcap has about one IP into a single response — MaxMind geo, PTR
        records, bot-CIDR membership, every `bot_observed_ips` attribution row (cidr_match /
        ua_match / vuln_match / manual), trust-list status, and cross-site behavioural data over
        the last 1–7 days from `request_events`: request count, distinct JA4s/UAs/sites,
        status-code mix, asset_ratio, vuln-probe hits, every JA4 observed from this IP with
        classification, the top user agents, and cross-customer `edge_action` votes summed across
        every JA4 the IP has used. Window is capped at 7 days (the `request_events` raw TTL).
      parameters:
        - { name: ip,          in: query, required: true,  schema: { type: string, example: "203.0.113.5" } }
        - { name: window_days, in: query, required: false, schema: { type: integer, default: 7, minimum: 1, maximum: 7 } }
      responses:
        "200": { description: The composite intelligence row., content: { application/json: { schema: { $ref: "#/components/schemas/IpIntelligenceResponse" } } } }
        "400": { description: Missing or invalid `ip`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: API key required (anonymous trial cannot use this endpoint)., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [IP]
      operationId: getIpIntelligencePost
      summary: Cross-site single-IP investigation (POST body)
      description: Same as the GET form but accepts the parameters in a JSON body.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ip]
              properties:
                ip:          { type: string, example: "203.0.113.5" }
                window_days: { type: integer, default: 7, minimum: 1, maximum: 7 }
      responses:
        "200": { description: The composite intelligence row., content: { application/json: { schema: { $ref: "#/components/schemas/IpIntelligenceResponse" } } } }
        "400": { description: Missing or invalid `ip`., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: API key required., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /monitors:
    get:
      tags: [Monitors]
      operationId: listMonitors
      summary: List your monitors
      responses:
        "200": { description: Your monitors + per-plan max., content: { application/json: { schema: { $ref: "#/components/schemas/MonitorsListResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    post:
      tags: [Monitors]
      operationId: createMonitor
      summary: Create a monitor
      description: >
        Validates the supplied capture / extract spec (must parse the same way `/api/v1/capture` or
        `/api/v1/extract` would) and enforces the per-plan count + minimum-interval limits.
        Returns the persisted monitor — including, on this call **only**, the freshly minted
        webhook HMAC secret. Save it; it can't be retrieved later.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MonitorCreateRequest" }
      responses:
        "201": { description: The created monitor (includes one-shot webhook secret)., content: { application/json: { schema: { $ref: "#/components/schemas/MonitorResponse" } } } }
        "400": { description: Invalid spec / params., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "403": { description: Plan limit exceeded (max monitors)., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "401": { description: Missing or invalid API key., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /monitors/{publicId}:
    parameters:
      - { name: publicId, in: path, required: true, schema: { type: string, example: "Xy9KqZ7mNvB2" } }
    get:
      tags: [Monitors]
      operationId: getMonitor
      summary: Fetch a single monitor
      responses:
        "200": { description: The monitor., content: { application/json: { schema: { $ref: "#/components/schemas/MonitorResponse" } } } }
        "404": { description: No such monitor (or not yours)., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    patch:
      tags: [Monitors]
      operationId: updateMonitor
      summary: Update a monitor
      description: Whole-spec replace (no partial updates in v1). Webhook secret rotates only when `alertWebhookUrl` transitions from null to set.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MonitorCreateRequest" }
      responses:
        "200": { description: Updated monitor., content: { application/json: { schema: { $ref: "#/components/schemas/MonitorResponse" } } } }
        "400": { description: Invalid spec / params., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
        "404": { description: No such monitor., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    delete:
      tags: [Monitors]
      operationId: deleteMonitor
      summary: Delete a monitor
      description: Hard-deletes the monitor. Check history (`monitor_checks`) is removed by the daily TTL sweeper.
      responses:
        "200": { description: Confirmation., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { deleted: { type: boolean }, publicId: { type: string } } } } } } } }
        "404": { description: No such monitor., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /monitors/{publicId}/pause:
    parameters:
      - { name: publicId, in: path, required: true, schema: { type: string } }
    post:
      tags: [Monitors]
      operationId: pauseMonitor
      summary: Pause a monitor (the scheduler skips it; state is preserved)
      responses:
        "200": { description: Paused., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { publicId: { type: string }, paused: { type: boolean } } } } } } } }
        "404": { description: No such monitor., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /monitors/{publicId}/resume:
    parameters:
      - { name: publicId, in: path, required: true, schema: { type: string } }
    post:
      tags: [Monitors]
      operationId: resumeMonitor
      summary: Resume a paused monitor
      responses:
        "200": { description: Resumed., content: { application/json: { schema: { type: object, properties: { version: { type: string }, requestId: { type: string }, data: { type: object, properties: { publicId: { type: string }, paused: { type: boolean } } } } } } } }
        "404": { description: No such monitor., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /monitors/{publicId}/checks:
    parameters:
      - { name: publicId, in: path, required: true, schema: { type: string } }
      - { name: limit,    in: query, required: false, schema: { type: integer, default: 100, minimum: 1, maximum: 1000 } }
    get:
      tags: [Monitors]
      operationId: listMonitorChecks
      summary: Recent checks for one monitor (TTL 30 days)
      responses:
        "200": { description: Check history (newest first)., content: { application/json: { schema: { $ref: "#/components/schemas/MonitorChecksResponse" } } } }
        "404": { description: No such monitor., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

  /monitors/{publicId}/uptime:
    parameters:
      - { name: publicId, in: path, required: true, schema: { type: string } }
      - { name: days,     in: query, required: false, schema: { type: integer, default: 30, minimum: 1, maximum: 365 } }
    get:
      tags: [Monitors]
      operationId: getMonitorUptime
      summary: Uptime ratio over the last N days
      description: "{passed} / {total} over the last `days` days. `uptimeRatio` is null when no checks recorded in the window."
      responses:
        "200": { description: Uptime ratio., content: { application/json: { schema: { $ref: "#/components/schemas/MonitorUptimeResponse" } } } }
        "404": { description: No such monitor., content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

components:
  securitySchemes:
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
    ingestBearer:
      type: http
      scheme: bearer
      bearerFormat: "ingest_<32 hex>"
      description: >
        Per-site ingest token issued by `POST /sites/{id}/ingest_keys`. Sent as
        `Authorization: Bearer ingest_…`. Scoped to a single site; revoke via the same endpoint.
        These tokens are distinct from urlcap API keys and authenticate only the `/ingest/*` paths.
  schemas:
    NameValue:
      type: object
      properties:
        name:  { type: string }
        value: { type: string }
      required: [name]
    CaptureSpec:
      type: object
      required: [url]
      properties:
        url:             { type: string, example: "https://httpbin.org/post?x=1" }
        method:          { type: string, enum: [GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH], default: GET }
        httpVersion:     { type: string, default: "HTTP/1.1" }
        headers:         { type: array, items: { $ref: "#/components/schemas/NameValue" }, description: "Sent to the wire in this exact order. Supplying any disables the default User-Agent/Accept." }
        body:            { type: string, default: "" }
        bodyEncoding:    { type: string, default: "UTF-8" }
        followRedirects: { type: boolean, default: false }
        timeoutMs:       { type: integer, default: 10000, minimum: 1000, maximum: 30000 }
        webBotAuth:      { type: boolean, default: false, description: "Sign the outbound request with urlcap's Web Bot Auth signature. Adds Signature-Agent / Signature-Input / Signature headers (Ed25519). Target sites verify via the JWKS at /.well-known/http-message-signatures-directory." }
        userAgent:       { type: string, description: "Explicit User-Agent override. Wins over `userAgentProfile`. Ignored when `headers[]` is non-empty (the caller is in full-control mode and we don't second-guess). When set on its own, urlcap still adds a default `Accept: */*`.", example: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" }
        userAgentProfile:{ type: string, description: "Key into the operator-managed `user_agent_profiles` table. Resolves to a UA string + optional default headers (Accept, Accept-Language, sec-ch-ua-*). Unknown keys silently fall through to the default UA. Discover available keys with `GET /api/v1/user_agent_profiles`.", example: "chrome-latest-mac" }
        proxy:
          type: object
          properties:
            host:     { type: string }
            port:     { type: integer }
            user:     { type: string }
            password: { type: string }
    Cookie:
      type: object
      properties:
        name:     { type: string }
        value:    { type: string }
        domain:   { type: string }
        path:     { type: string }
        expires:  { type: string }
        maxAge:   { type: string }
        sameSite: { type: string }
        secure:   { type: boolean }
        httpOnly: { type: boolean }
      required: [name, value]
    UserAgentProfile:
      type: object
      description: One row of the operator-managed catalogue.
      properties:
        key:           { type: string, example: "chrome-latest-mac" }
        description:   { type: string }
        userAgent:     { type: string }
        browserEngine: { type: string, enum: [chrome, firefox, firefox_esr, edge], description: "HtmlUnit BrowserVersion key used by /extract. /capture only consumes the UA string." }
        extraHeaders:  { type: object, additionalProperties: { type: string }, description: "Default headers the profile injects (Accept, Accept-Language, sec-ch-ua-*, etc.). Only sent when the caller doesn't supply an explicit `headers[]`." }
      required: [key, description, userAgent, browserEngine]
    UserAgentProfilesResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            profiles: { type: array, items: { $ref: "#/components/schemas/UserAgentProfile" } }
    CaptureRequestInfo:
      type: object
      properties:
        url:             { type: string }
        method:          { type: string }
        httpVersion:     { type: string }
        scheme:          { type: string }
        host:            { type: string }
        port:            { type: integer }
        path:            { type: string }
        query:           { type: [string, "null"] }
        queryParameters: { type: array, items: { $ref: "#/components/schemas/NameValue" } }
        headers:         { type: array, items: { $ref: "#/components/schemas/NameValue" } }
        followRedirects: { type: boolean }
        body:            { type: string }
        bodyEncoding:    { type: string }
        technology:      { type: string, example: "reactor-netty" }
    CaptureResponseInfo:
      type: object
      properties:
        status:        { type: integer, example: 200 }
        statusText:    { type: string, example: "OK" }
        httpVersion:   { type: string, example: "HTTP/1.1" }
        headers:       { type: array, items: { $ref: "#/components/schemas/NameValue" }, description: "In the exact order received." }
        cookies:       { type: array, items: { $ref: "#/components/schemas/Cookie" } }
        contentType:   { type: [string, "null"] }
        charset:       { type: [string, "null"] }
        contentLength: { type: integer }
        bodyBytes:     { type: integer }
        bodyTruncated: { type: boolean }
        body:          { type: string }
        bodyEncoding:  { type: string }
        timings:
          type: object
          description: Wall-clock total + phase-level breakdown. Phase fields are absent when their lifecycle hook didn't fire (e.g. a pooled keep-alive connection skips DNS / connect).
          properties:
            totalMs:       { type: integer, description: "Wall-clock from request start through body fully consumed." }
            dnsMs:         { type: integer, description: "DNS resolution + scheduling, from request start to TCP connect attempt." }
            connectMs:     { type: integer, description: "TCP socket connect + TLS handshake (when SSL). For pooled connections this is absent." }
            requestSendMs: { type: integer, description: "Time to write request bytes to the wire." }
            ttfbMs:        { type: integer, description: "Time-to-first-byte — server processing + first response byte over the wire (request-sent → response headers received)." }
            bodyMs:        { type: integer, description: "Response body download — first response byte through body fully consumed." }
            resolvedIp:    { type: string,  description: "The A/AAAA address the connection actually used (useful when the hostname has multiple IPs)." }
    CaptureResponse:
      type: object
      properties:
        version:   { type: string, example: "1" }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            request:  { $ref: "#/components/schemas/CaptureRequestInfo" }
            response: { $ref: "#/components/schemas/CaptureResponseInfo" }
      required: [version, requestId, data]

    ExtractField:
      type: object
      description: A sub-field extracted relative to each element of an `items` extractor.
      properties:
        name:     { type: string }
        selector: { type: string, description: "For HTML: CSS by default, or 'xpath:...', resolved relative to the item element. For JSON content: a JSONPath expression where `$` is the item ('jsonpath:' prefix optional; an empty string means the item itself). `attr` does not apply to JSON content." }
        type:     { type: string, enum: [text, attr, value], default: text }
        attr:     { type: string, description: "Attribute name when type=attr (HTML only)." }
      required: [name, selector]
    ExtractExtractor:
      type: object
      properties:
        name:     { type: string }
        selector: { type: string, description: "For HTML: a CSS selector (default), or 'xpath:...'. For JSON content: a JSONPath expression ('jsonpath:' prefix optional)." }
        type:     { type: string, enum: [text, attr, list, items, value], description: "HTML: text (default) | attr | list | items. JSON content: value (default — the matched value; a multi-valued path yields an array) | list (always an array) | items (each matched node → an object built from `fields`). `attr` is HTML only." }
        attr:     { type: string, description: "Attribute name when type=attr (also applies per-element for type=list). HTML only." }
        fields:   { type: array, items: { $ref: "#/components/schemas/ExtractField" }, description: "Used when type=items." }
      required: [name, selector]
    ExtractAction:
      type: object
      properties:
        type:     { type: string, enum: [fill, select, click, wait, navigate] }
        selector: { type: string, description: "Target element (fill, select, click)." }
        value:    { type: string, description: "Value to set (fill, select)." }
        ms:       { type: integer, description: "Milliseconds to wait (wait). Default 1000." }
        url:      { type: string, description: "URL to load (navigate)." }
      required: [type]
    ExtractPagination:
      type: object
      properties:
        strategy:            { type: string, enum: [sequential, link_tour], default: sequential }
        next_selector:       { type: string, description: "Element clicked to advance (sequential)." }
        link_selector:       { type: string, description: "Matches all pagination links (link_tour)." }
        max_pages:           { type: integer, default: 10 }
        wait_ms:             { type: integer, default: 1000 }
        stop_when_missing:   { type: boolean, default: true }
        per_page_extractors: { type: array, items: { $ref: "#/components/schemas/ExtractExtractor" } }
    ExtractSpec:
      type: object
      required: [url]
      properties:
        url:        { type: string, example: "https://example.com/search" }
        search_id:  { type: string, description: "Optional correlation id you choose; echoed in the result." }
        content:    { type: string, enum: [json], description: "Set to `json` to process the response body as JSON (selectors are then JSONPath expressions). If omitted, the engine auto-detects: an HTML response is treated as a page, anything else as JSON. `actions` and `pagination` are ignored for JSON content." }
        actions:    { type: array, items: { $ref: "#/components/schemas/ExtractAction" } }
        extractors: { type: array, items: { $ref: "#/components/schemas/ExtractExtractor" } }
        pagination: { $ref: "#/components/schemas/ExtractPagination" }
        webBotAuth: { type: boolean, default: false, description: "Sign every outbound request the headless browser makes with urlcap's Web Bot Auth signature (Ed25519). Target sites verify via the JWKS at /.well-known/http-message-signatures-directory." }
        userAgent:        { type: string, description: "Explicit User-Agent override for both the wire header and in-page `navigator.userAgent`. Wins over `userAgentProfile`. JS engine stays on the default (Chromium); for full engine selection, use `userAgentProfile`." }
        userAgentProfile: { type: string, description: "Key into the operator-managed `user_agent_profiles` table. Selects both the UA string AND the HtmlUnit `BrowserVersion` (CHROME / FIREFOX / FIREFOX_ESR / EDGE), so JS-fingerprinted targets see a coherent (engine, UA) pairing. Discover keys with `GET /api/v1/user_agent_profiles`. Caveat: `safari-*` profiles ship a Safari UA on the CHROME engine — `navigator.vendor` will still read `Google Inc.`", example: "chrome-latest-mac" }
    ExtractAccepted:
      type: object
      properties:
        version:   { type: string, example: "1" }
        taskId:    { type: string, format: uuid }
        status:    { type: string, example: "pending" }
        statusUrl: { type: string, example: "/api/v1/extract/2b9a0c3e-7d11-4f44-9a8c-2c1d4e5f6a7b" }
        requestId: { type: string, format: uuid }
      required: [version, taskId, status]
    ExtractTaskSummary:
      type: object
      properties:
        taskId:           { type: string, format: uuid }
        status:           { type: string, enum: [pending, running, succeeded, failed] }
        url:              { type: [string, "null"] }
        httpRequestCount: { type: integer }
        createdAt:        { type: [string, "null"], format: date-time }
        finishedAt:       { type: [string, "null"], format: date-time }
        error:            { type: [string, "null"] }
    ExtractTask:
      type: object
      properties:
        version:          { type: string, example: "1" }
        taskId:           { type: string, format: uuid }
        status:           { type: string, enum: [pending, running, succeeded, failed] }
        url:              { type: [string, "null"] }
        httpRequestCount: { type: integer }
        createdAt:        { type: [string, "null"], format: date-time }
        startedAt:        { type: [string, "null"], format: date-time }
        finishedAt:       { type: [string, "null"], format: date-time }
        result:           { type: object, description: "Present when status=succeeded — top-level extractor results, plus search_id, plus a `pages` array when pagination was used. List/items entries are stamped with result_global_id / result_relative_id / result_page.", additionalProperties: true }
        error:
          type: object
          description: "Present when status=failed."
          properties:
            type:    { type: string, example: job_failed }
            message: { type: string }
      required: [version, taskId, status]

    IpRange:
      type: object
      properties:
        id:           { type: integer, format: int64 }
        cidr:         { type: string, example: "10.0.0.0/8" }
        family:       { type: integer, enum: [4, 6] }
        prefixLength: { type: integer }
        label:        { type: [string, "null"] }
        createdAt:    { type: [string, "null"], format: date-time }
    IpContainsResponse:
      type: object
      properties:
        version:   { type: string, example: "1" }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            ip:       { type: string }
            cidr:     { type: string, description: "Present for the single-CIDR form." }
            contains: { type: boolean, description: "Present for the single-CIDR form." }
            range:
              type: object
              description: "Details of the CIDR (single-CIDR form only)."
              properties:
                cidr:           { type: string }
                family:         { type: integer, enum: [4, 6] }
                prefixLength:   { type: integer }
                networkAddress: { type: string }
                lastAddress:    { type: string }
            results:
              type: array
              description: "Present for the batch (`cidrs`) form."
              items:
                type: object
                properties: { cidr: { type: string }, contains: { type: boolean } }
    IpLookupResponse:
      type: object
      properties:
        version:   { type: string, example: "1" }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            ip:         { type: string }
            family:     { type: integer, enum: [4, 6] }
            matchCount: { type: integer }
            matches:
              type: array
              items:
                type: object
                properties:
                  id:           { type: integer, format: int64 }
                  cidr:         { type: string }
                  family:       { type: integer, enum: [4, 6] }
                  prefixLength: { type: integer }
                  label:        { type: [string, "null"] }
    IpRangeAdded:
      type: object
      properties:
        version:   { type: string, example: "1" }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            id:             { type: integer, format: int64 }
            cidr:           { type: string }
            family:         { type: integer, enum: [4, 6] }
            prefixLength:   { type: integer }
            networkAddress: { type: string }
            lastAddress:    { type: string }
            label:          { type: [string, "null"] }
    TotpCode:
      type: object
      properties:
        code:        { type: string, description: "Current one-time password, zero-padded to `digits`.", example: "492039" }
        digits:      { type: integer, example: 6 }
        period:      { type: integer, example: 30 }
        algorithm:   { type: string, enum: [SHA1, SHA256, SHA512], example: SHA1 }
        expiresIn:   { type: integer, description: "Seconds until `code` rotates.", example: 14 }
      required: [code]
    TotpResponse:
      type: object
      properties:
        version:   { type: string, example: "1" }
        requestId: { type: string, format: uuid, example: "9f1c0b7a-3e2d-4a51-9b88-2f6c1e7d4a02" }
        data:      { $ref: "#/components/schemas/TotpCode" }
      required: [version, requestId, data]
    ErrorResponse:
      type: object
      properties:
        version: { type: string, example: "1" }
        error:
          type: object
          properties:
            type:    { type: string, enum: [invalid_request, invalid_uri, unauthorized, rate_limited, upstream_error, not_found, method_not_allowed, service_busy, job_failed, internal_error], example: invalid_uri }
            message: { type: string }
          required: [type, message]
      required: [version, error]
    Schedule:
      type: object
      properties:
        id:         { type: string, format: uuid }
        kind:       { type: string, enum: [capture, extract] }
        name:       { type: [string, "null"] }
        cron:       { type: [string, "null"], description: Cron expression for a recurring schedule; null for one-shot., example: "*/15 * * * *" }
        runAt:      { type: [string, "null"], format: date-time, description: One-shot run time; null for recurring. }
        timezone:   { type: string, example: Europe/Madrid }
        status:     { type: string, enum: [active, paused, done, disabled] }
        nextRunAt:  { type: [string, "null"], format: date-time }
        lastRunAt:  { type: [string, "null"], format: date-time }
        runs:       { type: integer }
        maxRuns:    { type: [integer, "null"] }
        until:      { type: [string, "null"], format: date-time }
        createdAt:  { type: string, format: date-time }
        capture:    { $ref: "#/components/schemas/CaptureSpec", description: "Present when kind = capture." }
        extract:    { $ref: "#/components/schemas/ExtractSpec", description: "Present when kind = extract." }
      required: [id, kind, timezone, status, runs, createdAt]
    ScheduleRun:
      type: object
      properties:
        runNo:        { type: integer }
        scheduledFor: { type: [string, "null"], format: date-time }
        startedAt:    { type: string, format: date-time }
        finishedAt:   { type: [string, "null"], format: date-time }
        status:       { type: string, enum: [running, ok, error] }
        httpStatus:   { type: [integer, "null"], description: The captured response's HTTP status (when ok). }
        error:        { type: [string, "null"] }
        result:       { description: "The full capture result (only on single-run lookups): { version, requestId, data: { request, response } }.", oneOf: [ { $ref: "#/components/schemas/CaptureResponse" }, { type: "null" } ] }
      required: [runNo, startedAt, status]

    IsBotMatch:
      type: object
      properties:
        cidr:         { type: string, example: "66.249.66.0/27" }
        active:       { type: boolean, description: "True iff the CIDR was live at the query's time point — i.e. at `now` by default, or at `date` when supplied. Always true for plain queries; useful when querying historical or as-of-date data." }
        addedAt:      { type: [string, "null"], format: date-time, description: "When this CIDR first appeared in the bot's published list (from the live row's `created_at`, or from the historical row's `added_at` for retired records)." }
        removedAt:    { type: [string, "null"], format: date-time, description: "When this CIDR was replaced out of the bot's published list. `null` when the CIDR is still current." }
        botGroup:
          type: object
          properties:
            id:          { type: integer }
            description: { type: string }
            honoursRobots:
              type: object
              description: >-
                Whether this bot honours each aspect of robots.txt. `null` = we haven't researched
                it; `true` = the operator publicly commits and we have no documented violation;
                `false` = at least one credible report of the bot ignoring this rule (the "ever
                skipped, even once" semantics).
              properties:
                robots:     { type: [boolean, "null"], description: "Overall — honours Disallow rules." }
                crawlDelay: { type: [boolean, "null"], description: "Honours the Crawl-delay directive (e.g. Googlebot explicitly does not)." }
                allow:      { type: [boolean, "null"], description: "Honours the Allow directive (some legacy bots only honour Disallow)." }
                sitemap:    { type: [boolean, "null"], description: "Honours the Sitemap directive." }
        searchEngine: { type: object, properties: { id: { type: integer }, description: { type: string } } }
        categories:
          type: array
          items: { type: object, properties: { code: { type: string }, confidence: { type: string, enum: [low, medium, high] } } }
    IsBotSingle:
      type: object
      properties:
        ip:         { type: string }
        family:     { type: [integer, "null"], enum: [4, 6, null] }
        isBot:      { type: boolean }
        matchCount: { type: integer }
        matches:    { type: array, items: { $ref: "#/components/schemas/IsBotMatch" } }
        reverseDns: { $ref: "#/components/schemas/ReverseDnsAnswer", description: "Present only when the request supplied `reverseDns=true`." }
        error:      { type: [string, "null"], description: "Present when the IP could not be parsed." }
    IsBotResponse:
      type: object
      description: "Single-IP shape returns the per-IP fields at top level (under `data`); batch shape returns them as items of `data.results`."
      properties:
        version:   { type: string, example: "1" }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            ip:         { type: string,  description: "Present in single-IP form." }
            family:     { type: integer, description: "Present in single-IP form (4 or 6)." }
            isBot:      { type: boolean, description: "Present in single-IP form." }
            matchCount: { type: integer }
            matches:    { type: array, items: { $ref: "#/components/schemas/IsBotMatch" }, description: "Present in single-IP form." }
            reverseDns: { $ref: "#/components/schemas/ReverseDnsAnswer", description: "Single-IP form, only when the request supplied `reverseDns=true`." }
            count:      { type: integer, description: "Batch form: number of IPs in the request." }
            results:    { type: array, items: { $ref: "#/components/schemas/IsBotSingle" }, description: "Batch form: per-IP results." }
            asOf:       { type: string, format: date-time, description: "Echoed back when the request supplied `date` — the point-in-time the lookup was answered against." }
            historical: { type: boolean, description: "Echoed back when `historical=true` was supplied." }
            cache:
              type: object
              description: "Diagnostics for the in-memory snapshot used to answer the request."
              properties:
                entries: { type: integer, description: "Total CIDRs preloaded across all bots (live + retired)." }
                bots:    { type: integer, description: "Distinct bot groups indexed." }
                builtAt: { type: string, format: date-time }

    ReverseDnsAnswer:
      type: object
      description: "A PTR lookup result for one IP."
      properties:
        names:      { type: array, items: { type: string }, description: "PTR names (trailing dots stripped, sorted). Empty when there's no PTR record." }
        ttlSeconds: { type: integer, description: "Seconds until this cache entry expires (the minimum TTL across the records, clamped 30..3600; or 300 for negative answers)." }
        cached:     { type: boolean, description: "True when this answer was served from the in-process cache without hitting the upstream resolver." }
        error:      { type: [string, "null"], description: "Set when the lookup itself failed (timeout, server failure). NXDOMAIN / no-PTR is not an error — it returns empty `names` with `error: \"no PTR record\"`." }
    ReverseDnsForwardCheck:
      type: object
      description: "One forward-DNS confirmation for a PTR name."
      properties:
        name:       { type: string, description: "The PTR name being forward-resolved." }
        matched:    { type: boolean, description: "True iff at least one A/AAAA answer for this name equals the original IP (FCrDNS match)." }
        ttlSeconds: { type: integer, description: "Seconds until this forward-cache entry expires." }
        cached:     { type: boolean }
        addresses:  { type: array, items: { type: string }, description: "A + AAAA answers for `name`." }
        error:      { type: [string, "null"], description: "Set when the forward lookup itself failed." }
    ReverseDnsSingle:
      type: object
      properties:
        ip:               { type: string }
        family:           { type: [integer, "null"], enum: [4, 6, null] }
        names:            { type: array, items: { type: string } }
        ttlSeconds:       { type: integer }
        cached:           { type: boolean }
        forwardConfirmed: { type: boolean, description: "Only when the request supplied `forwardConfirm=true`. True iff at least one PTR forward-resolves back to the original IP." }
        forwardChecks:    { type: array, items: { $ref: "#/components/schemas/ReverseDnsForwardCheck" }, description: "Only when the request supplied `forwardConfirm=true`." }
        error:            { type: [string, "null"], description: "Set when the IP could not be parsed or the PTR lookup itself failed." }
    ReverseDnsResponse:
      type: object
      description: "Single-IP shape returns the per-IP fields at top level (under `data`); batch shape returns them as items of `data.results`."
      properties:
        version:   { type: string, example: "1" }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            ip:               { type: string, description: "Present in single-IP form." }
            family:           { type: integer, description: "Present in single-IP form (4 or 6)." }
            names:            { type: array, items: { type: string }, description: "Present in single-IP form." }
            ttlSeconds:       { type: integer, description: "Present in single-IP form." }
            cached:           { type: boolean, description: "Present in single-IP form." }
            forwardConfirmed: { type: boolean, description: "Single-IP form, only when `forwardConfirm=true`." }
            forwardChecks:    { type: array, items: { $ref: "#/components/schemas/ReverseDnsForwardCheck" }, description: "Single-IP form, only when `forwardConfirm=true`." }
            count:            { type: integer, description: "Batch form: number of IPs in the request." }
            results:          { type: array, items: { $ref: "#/components/schemas/ReverseDnsSingle" }, description: "Batch form: per-IP results." }
            forwardConfirm:   { type: boolean, description: "Echoed back when `forwardConfirm=true` was supplied." }

    RobotsRule:
      type: object
      properties:
        type:    { type: string, enum: [allow, disallow] }
        pattern: { type: string, description: "Path pattern; `*` matches any chars, `$` at end anchors to end-of-string." }
    RobotsGroup:
      type: object
      properties:
        userAgents: { type: array, items: { type: string }, description: "All user-agents this rule block applies to (a single block can name several)." }
        rules:      { type: array, items: { $ref: "#/components/schemas/RobotsRule" } }
    RobotsFetchResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string }
        data:
          type: object
          properties:
            site:           { type: string }
            requestedUrl:   { type: string }
            finalUrl:       { type: string, description: "The URL after redirects." }
            status:         { type: integer, description: "HTTP status from the upstream fetch; 0 if we never got a response." }
            contentType:    { type: string }
            sizeBytes:      { type: integer }
            contentSha256:  { type: string }
            fetchedAt:      { type: string, format: date-time }
            elapsedMs:      { type: integer }
            cached:         { type: boolean }
            ageSeconds:     { type: integer, description: "Seconds since this cache entry was created." }
            body:           { type: string, description: "Raw robots.txt body (omitted on errors)." }
            groups:         { type: array, items: { $ref: "#/components/schemas/RobotsGroup" } }
            sitemaps:       { type: array, items: { type: string } }
            extensions:     { type: object, additionalProperties: { type: array, items: { type: string } }, description: "Unknown directives (e.g. `crawl-delay`, `host`) grouped by lower-cased name." }
            effect:         { type: string, enum: [no_rules_unrestricted, restricted_by_error], description: "Set when the fetch was not OK but the RFC tells crawlers how to behave anyway." }
            error:          { type: string }
    RobotsCheckResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string }
        data:
          type: object
          properties:
            site:              { type: string }
            url:               { type: string }
            userAgent:         { type: string }
            allowed:           { type: boolean }
            reason:            { type: string, description: "Plain-English explanation: which rule matched, or why the default applied." }
            matchedRule:       { $ref: "#/components/schemas/RobotsRule", description: "The winning rule (null when none matched or the URL was allowed by default)." }
            matchedGroupUserAgents: { type: array, items: { type: string }, description: "The user-agents the matching group named." }
            robotsStatus:      { type: integer }
            robotsCached:      { type: boolean }
            robotsAgeSeconds:  { type: integer }
            robotsError:       { type: string, description: "Set when the upstream robots.txt fetch failed." }
    RobotsWatch:
      type: object
      properties:
        id:               { type: string, format: uuid }
        site:             { type: string }
        webhookUrl:       { type: string }
        webhookSecret:    { type: string, description: "HMAC-SHA256 signing key. Returned on create + GET-one; omitted from list endpoints." }
        frequencyMinutes: { type: integer, minimum: 15, maximum: 1440 }
        active:           { type: boolean }
        lastHash:         { type: string, description: "Hex sha256 of the most recently fetched body." }
        lastStatus:       { type: integer, description: "HTTP status of the most recent fetch." }
        lastCheckedAt:    { type: string, format: date-time }
        lastChangedAt:    { type: string, format: date-time, description: "When this watch last saw a different content hash." }
        createdAt:        { type: string, format: date-time }
      required: [id, site, frequencyMinutes, active, createdAt]
    RobotsSnapshot:
      type: object
      properties:
        id:                { type: integer, format: int64 }
        fetchedAt:         { type: string, format: date-time }
        httpStatus:        { type: integer }
        contentSha256:     { type: string }
        prevSha256:        { type: [string, "null"], description: "Hash of the prior snapshot; null for the very first snapshot." }
        sizeBytes:         { type: integer }
        changed:           { type: boolean, description: "Always true for stored snapshots — only changes are persisted." }
        webhookDelivered:  { type: boolean }
        webhookStatus:     { type: integer, description: "HTTP status returned by the webhook endpoint (0 if transport error)." }
        webhookError:      { type: string }
        body:              { type: string, description: "The robots.txt body at this snapshot; only present when `includeBody=true`." }

    WebBotAuthResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string }
        data:
          type: object
          properties:
            verified:          { type: boolean }
            reason:            { type: string, description: "Set when verified is false — why." }
            label:             { type: string, description: "The Signature-Input dictionary entry used." }
            keyid:             { type: string }
            algorithm:         { type: string, enum: [ed25519] }
            signatureAgent:    { type: string, description: "The directory URL the signature pointed at." }
            tag:               { type: string, description: "The signature's tag parameter; \"web-bot-auth\" for valid WBA signatures." }
            createdAt:         { type: string, format: date-time }
            expiresAt:         { type: string, format: date-time }
            expired:           { type: boolean }
            coveredComponents: { type: array, items: { type: string }, description: "The component identifiers the signature covered (e.g. @authority, @method, signature-agent, …)." }
            directory:
              type: object
              description: "Diagnostics on the directory fetch."
              properties:
                url:         { type: string }
                httpStatus:  { type: integer }
                keyCount:    { type: integer, description: "Usable Ed25519 keys found." }
                rawKeyCount: { type: integer, description: "Total `keys` entries in the JSON (including types we ignore)." }
                cached:      { type: boolean }
                ageSeconds:  { type: integer }
                error:       { type: string }
                kids:        { type: array, items: { type: string } }

    Dataset:
      type: object
      properties:
        id:        { type: string, format: uuid }
        name:      { type: string }
        type:      { type: string, enum: [ip, url], description: "ip = IP/CIDR (canonical CIDRs), url = absolute http(s) URLs." }
        itemType:  { type: integer, enum: [1, 2], description: "1 = ip, 2 = url." }
        history:   { type: boolean }
        size:      { type: integer, format: int64, description: "Current item count (present when a single dataset is returned, and on create — where it's the number of seed items stored)." }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: [string, "null"], format: date-time, description: "Present when a single dataset is returned." }
      required: [id, name, type, itemType, history, createdAt]
    DatasetItem:
      type: object
      properties:
        id:      { type: integer, format: int64 }
        value:   { type: string, description: "Canonical form — a CIDR for an ip dataset, a URL for a url dataset." }
        addedAt: { type: [string, "null"], format: date-time }
      required: [id, value]
    DatasetHistoryItem:
      allOf:
        - $ref: "#/components/schemas/DatasetItem"
        - type: object
          properties:
            removedAt: { type: string, format: date-time, description: "When a replace dropped this item." }
          required: [removedAt]
    DatasetItemsBody:
      type: object
      required: [items]
      properties:
        items: { type: array, minItems: 1, items: { type: string }, description: "Item values (alias: `values`). For ip datasets each is an IP or CIDR; for url datasets each is an absolute http(s) URL.", example: ["203.0.113.0/24", "198.51.100.7"] }
    DatasetContainsResponse:
      type: object
      properties:
        version:   { type: string, example: "1" }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            datasetId:       { type: string, format: uuid }
            type:            { type: string, enum: [ip, url] }
            value:           { type: string, description: "The value as supplied." }
            normalizedValue: { type: string, description: "The value after normalisation to the dataset's type." }
            contains:        { type: boolean, description: "Is this exact (normalised) item in the dataset?" }
            covered:         { type: boolean, description: "ip datasets only, and only when `value` is a single address: is it inside some CIDR stored in the dataset?" }
          required: [datasetId, type, value, normalizedValue, contains]

    # ── Sites ─────────────────────────────────────────────────────────────────────────────────────
    Site:
      type: object
      properties:
        id:       { type: integer, format: int64, description: "Numeric site id (immutable)." }
        publicId: { type: string, description: "12-char base62 public id. Use this in URLs over the numeric id where possible." }
        name:     { type: string }
      required: [id, name]
    SitesListResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            sites: { type: array, items: { $ref: "#/components/schemas/Site" } }
    SiteResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:      { $ref: "#/components/schemas/Site" }
    SiteDomain:
      type: object
      properties:
        id:       { type: integer, format: int64 }
        siteId:   { type: integer, format: int64 }
        hostname: { type: string }
        kind:     { type: string, enum: [doc, asset, api], description: "Hint at the traffic class served from this hostname." }
      required: [id, hostname, kind]
    SiteDomainsListResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId:  { type: integer, format: int64 }
            domains: { type: array, items: { $ref: "#/components/schemas/SiteDomain" } }
    SiteDomainResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:      { $ref: "#/components/schemas/SiteDomain" }
    IngestKeySummary:
      type: object
      properties:
        id:         { type: integer, format: int64 }
        label:      { type: string }
        createdAt:  { type: string, format: date-time }
        lastUsedAt: { type: [string, "null"], format: date-time }
        revoked:    { type: boolean }
      required: [id, createdAt, revoked]
    IngestKeysListResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId: { type: integer, format: int64 }
            keys:   { type: array, items: { $ref: "#/components/schemas/IngestKeySummary" } }
    IngestKeyIssued:
      type: object
      properties:
        id:      { type: integer, format: int64 }
        siteId:  { type: integer, format: int64 }
        label:   { type: string }
        token:   { type: string, description: "Cleartext token (returned exactly once — not retrievable later).", example: "ingest_8f3c0a1b2d4e5f6a7b8c9d0e1f2a3b4c5" }
        warning: { type: string }
      required: [id, siteId, token, warning]
    IngestKeyIssuedResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:      { $ref: "#/components/schemas/IngestKeyIssued" }

    # ── Ingest ────────────────────────────────────────────────────────────────────────────────────
    IngestEvent:
      type: object
      description: >
        One line of the NDJSON body sent to `POST /ingest/{site}/events`. Mandatory: `ts`, `ip`,
        `host`, `path`. `ja4` is optional — plain-HTTP requests come through with empty `ja4` and
        `ja4_hash=0`; they land in `request_events` but the JA4-keyed materialised views skip them.
        `ja4_hash` is derived server-side (SHA-256(ja4) → first 8 bytes as UInt64) if omitted.
        Enrichable fields like `asn`, `country`, `is_h2h3`, `is_browser_ua` are populated from the
        raw UA / MaxMind / `http_version` when omitted; you can override by sending an explicit value.
      required: [ts, ip, host, path]
      properties:
        ts:              { oneOf: [{ type: string, format: date-time }, { type: integer, format: int64, description: "Epoch milliseconds." }] }
        ja4:             { type: string, description: "JA4 fingerprint string. Empty for plain HTTP." }
        ja4_hash:        { type: integer, format: int64, description: "Unsigned UInt64 truncated to JVM long. Omit and the server derives it from `ja4`." }
        ja3_hash:        { type: string, description: "Optional, 32 hex chars (16 bytes)." }
        ua:              { type: string, description: "Raw User-Agent string." }
        ua_hash_128:     { type: string, description: "Override: 32 hex chars (16 bytes). If absent, computed from `ua`." }
        ip:              { type: string }
        asn:             { type: integer, description: "Override. If absent, looked up from MaxMind." }
        country:         { type: string, description: "ISO 3166-1 alpha-2. Override. If absent, looked up from MaxMind." }
        host:            { type: string, description: "Must already be attached to the site via `/sites/{id}/domains`." }
        path:            { type: string, description: "Server strips any `?query` downstream." }
        method:          { type: string, enum: [GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH] }
        status:          { type: integer }
        http_version:    { type: string, enum: [HTTP/1.0, HTTP/1.1, h2, h3], description: "Used to derive `is_h2h3` when not supplied." }
        is_h2h3:         { type: boolean }
        is_browser_ua:   { type: boolean }
        is_cacheable:    { type: boolean }
        heuristic_flag:  { type: boolean }
        challenge_issued:{ type: boolean }
        challenge_passed:{ type: boolean }
        request_bytes:   { type: integer }
        response_bytes:  { type: integer }
        has_cookie:      { type: boolean }
        has_referer:     { type: boolean }
        referer_host:    { type: string, description: "Referer's hostname only (no scheme/path)." }
        accept_language: { type: string }
        session_id:      { type: string, description: "Opaque client-supplied session identifier." }
        request_id:      { type: string, description: "Opaque per-request id (typically nginx `$request_id`). The key used by `/ingest/{site}/outcomes` to bind asynchronous verdicts back to this row." }
    IngestOutcome:
      type: object
      required: [request_id, kind]
      description: >
        One line of the NDJSON body sent to `POST /ingest/{site}/outcomes`. Tied back to an earlier
        event by `request_id`. urlcap resolves the JA4 hash server-side and stores it on the outcome
        row so verdicts survive the 7-day raw-event TTL.
      properties:
        ts:         { oneOf: [{ type: string, format: date-time }, { type: integer, format: int64 }] }
        request_id: { type: string }
        ja4_hash:   { type: integer, format: int64, description: "Optional client-side override. If 0/absent, urlcap resolves it from `request_events`." }
        kind:       { type: string, enum: [js_challenge, auth, purchase, edge_action] }
        verdict:    { type: string, description: "Allowed values depend on `kind` — see endpoint description." }
        score:      { type: number, format: float, description: "For kinds where a continuous quality signal exists (reCAPTCHA v3, hCaptcha enterprise, fraud scores)." }
        meta:       { type: object, additionalProperties: true, description: "Free-form JSON (or a JSON-encoded string). Common fields: `user_id_hash`, `order_id`, `amount_cents`." }
    IngestAccepted:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId:   { type: integer, format: int64 }
            accepted: { type: integer, description: "Rows that successfully bound and were enqueued for write." }
            rejected: { type: integer, description: "Rows that failed validation. See `errors[]`." }
            errors:   { type: array, items: { type: string }, description: "Per-row reasons for rejection." }

    # ── bot-ja4s blocklist ───────────────────────────────────────────────────────────────────────
    BotJa4Item:
      type: object
      properties:
        ja4:                       { type: string, description: "The blocklist key — feed this string into your edge ban table." }
        ja4_hash:                  { type: string, description: "Unsigned UInt64 as decimal string." }
        classification:            { type: string, enum: [known_bot, candidate] }
        bot_group:                 { type: string, description: "Present when `classification=known_bot` — e.g. `Googlebot`, `Bingbot`, `Vulnerability scanner`." }
        bot_group_id:              { type: integer, description: "Present when `classification=known_bot`." }
        score:                     { type: number, description: "Present when `classification=candidate` — discovery score (0–1)." }
        candidate_id:              { type: integer, format: int64, description: "Present when `classification=candidate`." }
        reqs:                      { type: integer, format: int64 }
        ips:                       { type: integer, format: int64 }
        active_days:               { type: integer }
        asset_ratio:               { type: number, description: "Share of requests served from asset-class paths (image/css/js/font/media). Browsers ≈ 0.9+; bots typically low." }
        first_seen:                { type: string, format: date-time }
        last_seen:                 { type: string, format: date-time }
        reqs_per_minute_peak:      { type: integer, description: "Max RPM bucket within the window." }
        reqs_per_minute_mean:      { type: number, description: "Mean RPM across active minutes only (zero buckets between bursts excluded)." }
        active_minutes:            { type: integer }
        burstiness:                { type: number, description: "stddev(reqs)/mean(reqs) across active 1-min buckets. 0 = perfectly uniform, >2 = highly bursty." }
        cross_customer_action:
          type: object
          description: "How many distinct sites have submitted explicit `edge_action` verdicts on this JA4 (last 30 days). Strong human-validated label."
          properties:
            sites_blocking:    { type: integer }
            sites_allowing:    { type: integer }
            sites_challenging: { type: integer }
        block_likely_on_this_site: { type: boolean, description: "Set when ≥10 samples and ≥80% of requests on this site returned 403 or 444 — your edge is already blocking even if you haven't told us." }
        block_likely_ratio:        { type: number, description: "The 403/444 share that triggered `block_likely_on_this_site`." }
      required: [ja4, ja4_hash, classification, reqs, ips, active_days, first_seen, last_seen]
    BotJa4sResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId:     { type: integer, format: int64 }
            windowDays: { type: integer }
            items:      { type: array, items: { $ref: "#/components/schemas/BotJa4Item" } }

    # ── JA4 read endpoints ───────────────────────────────────────────────────────────────────────
    Ja4SignalsResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId:             { type: integer, format: int64 }
            ja4Hash:            { type: string }
            calculatedAt:       { type: string, format: date-time }
            ja4:                { type: string }
            reqs_1h:            { type: integer, format: int64 }
            h2h3_ratio_1h:      { type: number, description: "Share of trailing-hour requests on HTTP/2 or HTTP/3." }
            browser_ratio_1h:   { type: number, description: "Share served with a UA Yauaa classifies as a browser." }
            cache_ratio_1h:     { type: number }
            heuristic_ratio_1h: { type: number }
            unique_ips_1h:      { type: integer, format: int64 }
            unique_uas_1h:      { type: integer, format: int64 }
            unique_paths_1h:    { type: integer, format: int64 }
            reqs_rank_1h:       { type: integer, description: "Per-site rank by trailing-hour request count (1 = highest)." }
            reqs_quantile_1h:   { type: number, description: "Per-site quantile of the request count (0..1)." }
            ips_rank_1h:        { type: integer }
            ips_quantile_1h:    { type: number }
            uas_rank_1h:        { type: integer }
            paths_rank_1h:      { type: integer }
    Ja4IntelligenceResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId:                    { type: integer, format: int64 }
            ja4Hash:                   { type: string }
            windowDays:                { type: integer }
            calculatedAt:              { type: string, format: date-time }
            ja4:                       { type: string }
            reqs:                      { type: integer, format: int64 }
            unique_ips:                { type: integer, format: int64 }
            unique_uas:                { type: integer, format: int64 }
            unique_paths:              { type: integer, format: int64 }
            likely_os_name:            { type: string }
            likely_os_confidence:      { type: number, description: "0..1 — score IS the confidence; apply your own threshold (spec mentions ≥0.9 for `high`)." }
            likely_agent_name:         { type: string }
            likely_agent_confidence:   { type: number }
            likely_device_class:       { type: string, description: "e.g. `Desktop`, `Phone`, `Tablet`, `Robot`." }
            likely_device_confidence:  { type: number }
            ja4_diversity_score:       { type: number, description: "Placeholder in v1 (always 0.0)." }
            ua_diversity_score:        { type: number, description: "unique_uas / reqs." }
            ip_diversity_score:        { type: number, description: "unique_ips / reqs." }
            suspicious_score:          { type: number, description: "0..1 composite. 40% UA↔JA4 inconsistency (1 - min(agent, os) confidence), 40% UA rotation per IP (saturates at 5 UAs/IP), 20% JS-challenge fail rate. Hard-gated to 0 by any completed purchase or fewer than 20 reqs. Apply your own threshold; >= 0.40 typical for UA-rotating bots." }
            js_challenge_attempts:     { type: integer }
            js_challenge_passes:       { type: integer }
            js_challenge_pass_rate:    { type: number }
            auth_observations:         { type: integer }
            distinct_users:            { type: integer }
            purchases:                 { type: integer }
            total_purchase_cents:      { type: integer, format: int64 }
            last_purchase_at:          { type: [string, "null"], format: date-time }
            ja4_ua_consistency:        { type: number, description: "min(likely_agent_confidence, likely_os_confidence). 1.0 = JA4 always claims the same agent/OS; lower = UA-spoofing tell." }
    Ja4MetricsResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId:  { type: integer, format: int64 }
            ja4Hash: { type: string }
            ja4_1h:
              type: object
              description: Trailing-hour rollup over `ja4_agg_5m` (12 buckets). Empty object if the JA4 has no traffic in the window.
              properties:
                req_count:        { type: integer, format: int64 }
                unique_ips:       { type: integer, format: int64 }
                unique_uas:       { type: integer, format: int64 }
                unique_paths:     { type: integer, format: int64 }
                unique_hosts:     { type: integer, format: int64 }
                h2h3_ratio:       { type: number }
                browser_ua_ratio: { type: number }
                error_ratio:      { type: number, description: "Share of responses with status ≥ 400." }
                s403_ratio:       { type: number }
                s404_ratio:       { type: number }
                s429_ratio:       { type: number }
            ip_ja4_1h:
              type: object
              description: Present only when `ip=` is supplied.
              properties:
                ip:               { type: string }
                req_count:        { type: integer, format: int64 }
                unique_uas:       { type: integer, format: int64 }
                unique_paths:     { type: integer, format: int64 }
                unique_hosts:     { type: integer, format: int64 }
                browser_ua_ratio: { type: number }
                library_ua_ratio: { type: number, description: "Share with UA `agent_class ∈ {Special, Robot}` or `is_robot=1`." }
                h2h3_ratio:       { type: number }
                error_ratio:      { type: number }
                s404_ratio:       { type: number }
                first_seen:       { type: string, format: date-time }
                last_seen:        { type: string, format: date-time }
                is_new_24h:       { type: boolean }
            ja4_ua_24h_top:
              type: array
              description: Top-10 UA hashes seen with this JA4 over the last 24h.
              items:
                type: object
                properties:
                  ua_hash_128:  { type: string, description: "Hex (32 chars)." }
                  req_count:    { type: integer, format: int64 }
                  unique_ips:   { type: integer, format: int64 }
                  unique_asns:  { type: integer, format: int64 }
                  error_ratio:  { type: number }
                  share_of_ja4: { type: number, description: "This UA's share of the JA4's 24h request volume (sums to 1.0 across the top-N)." }
    Ja4ProfileValue:
      type: object
      properties:
        value:        { type: string }
        reqs:         { type: integer, format: int64 }
        unique_ips:   { type: integer, format: int64 }
        unique_uas:   { type: integer, format: int64 }
        unique_paths: { type: integer, format: int64 }
    Ja4ProfileResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId:  { type: integer, format: int64 }
            ja4Hash: { type: string }
            dim:     { type: string }
            days:    { type: integer }
            values:  { type: array, items: { $ref: "#/components/schemas/Ja4ProfileValue" } }

    # ── IP read endpoints ────────────────────────────────────────────────────────────────────────
    IpProfileResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId:         { type: integer, format: int64 }
            ip:             { type: string }
            days:           { type: integer }
            reqs:           { type: integer, format: int64 }
            unique_ja4s:    { type: integer, format: int64 }
            unique_uas:     { type: integer, format: int64 }
            unique_paths:   { type: integer, format: int64 }
            unique_hosts:   { type: integer, format: int64 }
            heuristic_reqs: { type: integer, format: int64 }
            challenge_reqs: { type: integer, format: int64 }
            first_seen:     { type: string, format: date-time }
            last_seen:      { type: string, format: date-time }
    IpIntelligenceResponse:
      type: object
      description: >
        Composite response — every signal urlcap has on the IP. Sub-blocks are independently
        populated; absence of a block means urlcap has no data of that type.
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            ip:           { type: string }
            family:       { type: integer, enum: [4, 6] }
            windowDays:   { type: integer }
            geo:
              type: object
              description: MaxMind GeoLite2 lookup.
              properties:
                country:  { type: string }
                city:     { type: string }
                asn:      { type: integer }
                asnOrg:   { type: string }
            reverseDns:
              type: object
              properties:
                names:      { type: array, items: { type: string } }
                ttlSeconds: { type: integer }
            isBot:
              type: object
              description: Match against the published-CIDR registry.
              properties:
                matched:    { type: boolean }
                botGroups:  { type: array, items: { type: string }, description: "Bot groups whose CIDR set contains this IP." }
            trustList:
              type: object
              properties:
                trustedByUsers: { type: integer, description: "Count of users who have added the IP (or a covering CIDR) to their trust list." }
                systemTrusted:  { type: boolean, description: "True if any trust entry belongs to the system user (signal modulation applies globally)." }
            botObservations:
              type: array
              description: Every `bot_observed_ips` row for this IP — per-bot-group attribution and reason.
              items:
                type: object
                properties:
                  botGroupId:  { type: integer }
                  botGroup:    { type: string }
                  source:      { type: string, enum: [cidr_match, ua_match, vuln_match, manual] }
                  observations:{ type: integer, format: int64 }
                  firstSeen:   { type: string, format: date-time }
                  lastSeen:    { type: string, format: date-time }
            behaviour:
              type: object
              description: ClickHouse `request_events` summary for this IP across all sites within the window.
              properties:
                reqs:           { type: integer, format: int64 }
                unique_ja4s:    { type: integer, format: int64 }
                unique_uas:     { type: integer, format: int64 }
                unique_sites:   { type: integer, format: int64 }
                asset_ratio:    { type: number }
                error_ratio:    { type: number }
                vuln_probe_hits:{ type: integer, format: int64, description: "Number of requests whose path matched the curated vuln-probe registry." }
            ja4s:
              type: array
              description: Every JA4 observed from this IP in the window, classified.
              items:
                type: object
                properties:
                  ja4:            { type: string }
                  ja4_hash:       { type: string }
                  classification: { type: string, enum: [known_bot, candidate, unknown, browser] }
                  bot_group:      { type: string }
                  score:          { type: number }
                  reqs:           { type: integer, format: int64 }
            userAgents:
              type: array
              description: Top-5 raw User-Agent strings from this IP in the window.
              items:
                type: object
                properties:
                  ua:           { type: string }
                  reqs:         { type: integer, format: int64 }
                  unique_paths: { type: integer, format: int64 }
            crossCustomerAction:
              type: object
              description: Sum of every `edge_action` outcome submitted by any site across every JA4 this IP has used, last 30 days.
              properties:
                sites_blocking:    { type: integer }
                sites_allowing:    { type: integer }
                sites_challenging: { type: integer }

    # ── Monitors ──────────────────────────────────────────────────────────────────────────────────
    MonitorCreateRequest:
      type: object
      required: [name, kind, spec, intervalSeconds]
      properties:
        name:                   { type: string, example: "prod API healthcheck" }
        kind:                   { type: string, enum: [capture, extract], description: "capture = fast HTTP fetch via /capture; extract = headless-browser run via /extract (slower, supports actions / JSON / SPA)." }
        spec:
          oneOf:
            - { $ref: "#/components/schemas/CaptureSpec" }
            - { $ref: "#/components/schemas/ExtractSpec" }
          description: "Inline capture or extract spec. Validated at create-time by the same parser the public /capture and /extract endpoints use; an unrunnable spec returns 400 here."
        userAgentProfile:       { type: string, description: "Optional — pick a UA persona from `/user_agent_profiles`. Applied to every check.", example: "chrome-latest-mac" }
        intervalSeconds:        { type: integer, minimum: 30, maximum: 86400, description: "Plan floor: free=300s, developer=60s, startup/business=30s. Tighter values are rejected with 400." }
        expectedStatus:         { type: integer, minimum: 100, maximum: 599, description: "Optional. If set, the check passes only when the response status matches exactly. If absent, any 2xx counts as a pass." }
        alertWebhookUrl:        { type: string, format: uri, description: "Optional. Receives an HMAC-signed POST on every state transition." }
        alertEmail:             { type: string, format: email, description: "Optional. Sent via the platform mailer on every state transition." }
        alertFailureThreshold:  { type: integer, minimum: 1, maximum: 10, default: 2, description: "Number of consecutive failures required to flip to `down`. Debounces flapping targets (single-shot 502s don't page you)." }
    Monitor:
      type: object
      properties:
        publicId:                  { type: string, example: "Xy9KqZ7mNvB2" }
        name:                      { type: string }
        kind:                      { type: string, enum: [capture, extract] }
        spec:                      { type: object, additionalProperties: true, description: "The original capture/extract spec." }
        userAgentProfile:          { type: string }
        intervalSeconds:           { type: integer }
        expectedStatus:            { type: integer }
        alertWebhookUrl:           { type: string }
        alertWebhookSecret:        { type: string, description: "Only present in the response to POST /monitors (i.e. immediately after create). Subsequent reads omit it; `alertWebhookSecretSet=true` indicates a secret exists." }
        alertWebhookSecretSet:     { type: boolean, description: "True when a webhook secret is stored. Returned by list / get; never together with the cleartext secret." }
        alertWebhookSecretWarning: { type: string, description: "Companion warning to `alertWebhookSecret` reminding the caller to save it." }
        alertEmail:                { type: string }
        alertFailureThreshold:     { type: integer }
        paused:                    { type: boolean }
        currentState:              { type: string, enum: [up, down, unknown] }
        lastCheckedAt:             { type: string, format: date-time }
        lastStateChangeAt:         { type: string, format: date-time }
        consecutiveFailures:       { type: integer }
        createdAt:                 { type: string, format: date-time }
        updatedAt:                 { type: string, format: date-time }
      required: [publicId, name, kind, intervalSeconds, paused, currentState, alertFailureThreshold]
    MonitorResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:      { $ref: "#/components/schemas/Monitor" }
    MonitorsListResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            max:      { type: [integer, "null"], description: "Per-plan max monitor count; null means unlimited." }
            monitors: { type: array, items: { $ref: "#/components/schemas/Monitor" } }
    MonitorCheck:
      type: object
      properties:
        ranAt:      { type: string, format: date-time }
        passed:     { type: boolean }
        httpStatus: { type: integer }
        latencyMs:  { type: integer, description: "Full check wall-clock — for capture monitors this matches `timings.totalMs` on the underlying capture, plus scheduler overhead." }
        sizeBytes:  { type: integer }
        error:      { type: string }
        dnsMs:      { type: integer, description: "DNS resolution time (capture monitors only). Absent on pooled / extract checks." }
        connectMs:  { type: integer, description: "TCP connect + TLS handshake (capture monitors only). Absent on pooled / extract checks." }
        ttfbMs:     { type: integer, description: "Time-to-first-byte (capture monitors only)." }
        bodyMs:     { type: integer, description: "Response body download time (capture monitors only)." }
        resolvedIp: { type: string,  description: "The IP the check actually connected to (useful when targets have multiple A/AAAA records)." }
      required: [ranAt, passed]
    MonitorChecksResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            publicId: { type: string }
            checks:   { type: array, items: { $ref: "#/components/schemas/MonitorCheck" } }
    MonitorUptimeResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            publicId:    { type: string }
            days:        { type: integer }
            uptimeRatio: { type: [number, "null"], minimum: 0, maximum: 1, description: "passed / total checks over the window. Null when there are no checks." }
            note:        { type: string, description: "Set when uptimeRatio is null." }

    # ── /sites/{id}/bot-ip-candidates ────────────────────────────────────────────────────────────
    SiteBotIpCandidatesResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId:               { type: integer, format: int64 }
            windowDays:           { type: integer }
            minScore:             { type: number }
            excludedClassified:   { type: integer, description: "How many already-attributed IPs were excluded from scoring (for transparency on what we filtered out)." }
            candidates:
              type: array
              items:
                type: object
                properties:
                  ip:        { type: string }
                  asn:       { type: integer }
                  country:   { type: string }
                  score:     { type: number, description: "0..1 composite (see endpoint description for component formula)." }
                  components:
                    type: object
                    properties:
                      block_ratio:     { type: number, description: "Share of this IP's requests on the site that got 4xx or 444." }
                      reqs:            { type: integer, format: int64 }
                      distinct_paths:  { type: integer, format: int64 }
                      distinct_ja4s:   { type: integer, format: int64 }
                      vuln_probe_hits: { type: integer, format: int64, description: "Distinct vuln-probe path IDs this IP hit." }
                  first_seen: { type: string, format: date-time }
                  last_seen:  { type: string, format: date-time }

    # ── /sites/{id}/url-stats + /sites/{id}/bot-traffic ──────────────────────────────────────────
    SiteUrlStatsResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId:        { type: integer, format: int64 }
            host:          { type: string }
            path:          { type: string }
            windowDays:    { type: integer }
            total:         { type: integer, format: int64 }
            ok_2xx:        { type: integer, format: int64 }
            redirect_3xx:  { type: integer, format: int64 }
            rejected_4xx:  { type: integer, format: int64 }
            error_5xx:     { type: integer, format: int64 }
            unique_ips:    { type: integer, format: int64 }
            unique_ja4:    { type: integer, format: int64 }
            by_status:
              type: array
              items:
                type: object
                properties:
                  status:     { type: integer }
                  count:      { type: integer, format: int64 }
                  unique_ips: { type: integer, format: int64 }
            by_day:
              type: array
              items:
                type: object
                properties:
                  day:   { type: string, format: date }
                  total: { type: integer, format: int64 }
                  ok:    { type: integer, format: int64 }
                  s4xx:  { type: integer, format: int64 }
                  s5xx:  { type: integer, format: int64 }
            rejected_sample:
              type: array
              description: Up to 25 most-recent non-2xx requests, newest first.
              items:
                type: object
                properties:
                  ts:     { type: string }
                  status: { type: integer }
                  ip:     { type: string }
                  asn:    { type: integer }
                  method: { type: string }
                  ja4:    { type: string }
    SiteBotTrafficResponse:
      type: object
      properties:
        version:   { type: string }
        requestId: { type: string, format: uuid }
        data:
          type: object
          properties:
            siteId:                 { type: integer, format: int64 }
            botGroupFilter:         { type: string, description: "The string the caller passed in." }
            windowDays:             { type: integer }
            matchedBotGroups:
              type: array
              description: Every bot_group whose description contains the filter string.
              items:
                type: object
                properties:
                  botGroupId:  { type: integer }
                  description: { type: string }
            ja4HashesUsedForFilter: { type: integer, description: "Count of JA4 hashes pulled from bot_observed_ja4s and used in the IN filter." }
            total:        { type: integer, format: int64 }
            ok_2xx:       { type: integer, format: int64 }
            redirect_3xx: { type: integer, format: int64 }
            rejected_4xx: { type: integer, format: int64 }
            error_5xx:    { type: integer, format: int64 }
            unique_ips:   { type: integer, format: int64 }
            unique_paths: { type: integer, format: int64 }
            by_status:
              type: array
              items:
                type: object
                properties:
                  status: { type: integer }
                  count:  { type: integer, format: int64 }
            by_day:
              type: array
              items:
                type: object
                properties:
                  day:   { type: string, format: date }
                  total: { type: integer, format: int64 }
                  ok:    { type: integer, format: int64 }
                  s4xx:  { type: integer, format: int64 }
                  s5xx:  { type: integer, format: int64 }
            recent_sample:
              type: array
              description: Up to 25 recent requests. Non-2xx are surfaced first so blockages are obvious.
              items:
                type: object
                properties:
                  ts:     { type: string }
                  host:   { type: string }
                  path:   { type: string }
                  status: { type: integer }
                  ip:     { type: string }
