openapi: 3.1.0

info:
  title: NSIN API
  version: "1.0.0"
  summary: Public REST API for NSIN CDN, DNS and edge-security management.
  description: |
    The NSIN API lets you manage everything you can manage from the panel:
    domains, DNS records, edge rules (cache, WAF, redirects, rate limiting, …),
    TLS certificates, analytics, uptime and domain sharing.

    ## Authentication

    Every endpoint in this reference is authenticated with an **API key**.
    Create one in the panel under *Settings → API keys*. Keys are shown once at
    creation time and are prefixed `nsin_`.

    Send the key either way — both are equivalent:

    ```
    Authorization: Bearer nsin_xxxxxxxxxxxxxxxxxxxx
    ```
    ```
    X-Api-Key: nsin_xxxxxxxxxxxxxxxxxxxx
    ```

    ### Read-only keys

    A key marked read-only may only issue `GET`, `HEAD` and `OPTIONS` requests.
    Any other method returns `403` with `{"error": "read-only API key"}`,
    regardless of the endpoint.

    ### What API keys cannot do

    Some parts of the product are deliberately unreachable with a key, so that a
    leaked key can never take over the account or spend money. These return
    `403` for **every** key, including full-access ones:

    | Surface | Reason |
    |---|---|
    | `/users/**` | Profile, password, sessions and API-key management. A key cannot mint or revoke keys. |
    | `/auth/**` | Login, registration, OTP. |
    | `/billing/**` | Plan catalogue and billing settings. |
    | `/admin/**` | Administrative surface. |
    | `POST /wallet/topup` | Moves money. |
    | `POST /subscriptions/purchase`, `/switch`, `/auto-renew` | Moves money. |
    | `POST /domains/{domain}/subscriptions/purchase`, `/switch`, `/auto-renew` | Moves money. |

    Reading subscription, feature, traffic-usage, invoice and wallet state *is*
    allowed — only the money-moving writes are blocked.

    ## Rate limiting

    Requests are limited **per key**, by default to 300 requests per minute.
    Exceeding it returns `429` with `{"error": "rate limit exceeded"}`.
    Panel (browser) traffic is limited separately and does not consume your key's
    budget.

    ## Conventions

    * **`{domain}` path parameter** — every path segment written as `{domain}` is
      the domain **name** (`example.com`), not a numeric id. Percent-encode it if
      it contains characters that are unsafe in a path segment.
    * **Errors** — all errors share one shape: `{"error": "human readable message"}`.
      See the `Error` schema.
    * **Timestamps** — RFC 3339 / ISO 8601 strings in UTC unless stated otherwise.
    * **Byte counts** — always bytes; **traffic and quota** values are documented
      per field.
    * **Access control** — a key inherits the permissions of the user who owns it.
      For a shared domain that is the role granted to that user (`viewer`,
      `editor`, `admin`); for your own domains it is `owner`. Endpoints document
      the permission they require, and return `403` when the role lacks it and
      `404` when the domain is not visible to you at all.

    ## Plan features

    Several endpoints are gated on the domain's active plan (analytics, logs,
    WAF, custom certificates, …). When the plan does not include the feature the
    response is `403` with an `error` explaining which feature is missing.

  contact:
    name: NSIN Support
    url: https://nsin.ir
  license:
    name: Proprietary

servers:
  - url: https://api.nsin.ir
    description: Production

security:
  - bearerAuth: []
  - apiKeyAuth: []

tags:
  - name: Domains
    description: Add, configure, verify and remove domains.
  - name: DNS Records
    description: DNS record CRUD, bulk operations, zone scan and import.
  - name: Gateways
    description: Ready-made records you can switch on for a domain in one call.
  - name: SSL
    description: Certificate status, manual issuance and custom certificate upload.
  - name: Rules
    description: Edge rules — cache, WAF, redirect, rewrite, rate limit, captcha, bot routing, origin selection, fingerprinting and error pages.
  - name: Cache
    description: Cache statistics, key browsing and purging.
  - name: Analytics
    description: Traffic analytics, request logs and ad-hoc queries over your own traffic.
  - name: Uptime
    description: Origin outage incidents and detection settings.
  - name: Recommendations
    description: Per-domain advisory checklist.
  - name: Sharing
    description: Domain members and invitations.
  - name: Billing
    description: Read-only access to subscriptions, features, traffic usage, invoices and wallet.
  - name: Support
    description: Support tickets.
  - name: Notifications
    description: The notification feed — domain, plan and account events.
  - name: Account
    description: Account-wide reads.

paths:

  # ---------------------------------------------------------------------------
  # Domains
  # ---------------------------------------------------------------------------

  /domains/:
    get:
      tags: [Domains]
      operationId: listDomains
      summary: List domains
      description: |
        Every domain you can access — owned and shared with you — each with a
        short SSL summary, its active subscription and your role on it.
      responses:
        "200":
          description: Domain list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/DomainWithSsl" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Domains]
      operationId: createDomain
      summary: Add a domain
      description: |
        Registers a domain on your account.

        * `dns_mode: managed` (default) — NSIN hosts the zone. The domain starts
          in `pending` until its nameservers point at the NSIN set returned by
          `GET /domains/ns-sets`, then flips to `active` automatically.
        * `dns_mode: external` — you keep DNS elsewhere. The domain starts in
          `unverified` and you prove ownership with the TXT record from the
          `verification` block, then call `POST /domains/{domain}/verify`.

        Existing records are scanned and imported in the background for managed
        domains.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DomainCreate" }
      responses:
        "200":
          description: Domain created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainDetail" }
        "400":
          description: Invalid or unsupported domain name.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "409":
          description: The domain already exists on this or another account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/ns-sets:
    get:
      tags: [Domains]
      operationId: listNameserverSets
      summary: List accepted nameserver sets
      description: |
        The nameserver sets a managed domain's delegation may match. The
        delegation must match **exactly one set in full** — nameservers from
        different sets cannot be mixed. The first set is the one shown in the
        panel and is the recommended choice.
      responses:
        "200":
          description: Accepted nameserver sets.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sets:
                    type: array
                    description: Each entry is one complete, acceptable nameserver set.
                    items:
                      type: array
                      items: { type: string }
                    examples:
                      - [["th.ns.nsin.ir", "ny.ns.nsin.ir", "eu.ns.nsin.ir"]]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Domains]
      operationId: getDomain
      summary: Get a domain
      description: |
        Full domain state, including nameserver/verification progress, your role
        and permissions on it, and every edge setting.
      responses:
        "200":
          description: Domain detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Domains]
      operationId: updateDomain
      summary: Update domain settings
      description: |
        Partial update — omitted fields are left unchanged. Requires the
        `domain.settings` permission.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DomainUpdate" }
      responses:
        "200":
          description: Updated domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainDetail" }
        "400":
          description: |
            Invalid value — e.g. `dns_mode` not `managed`/`external`,
            `cache_l2_ttl_days` outside 1–7, or `cache_cap_mb` not one of the
            allowed tiers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            Read-only key, insufficient role, or the requested `cache_cap_mb`
            exceeds what the domain's plan allows.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Domains]
      operationId: deleteDomain
      summary: Delete a domain
      description: |
        Removes the domain, its records, rules and DNS zone. Owner only
        (`domain.delete`). This cannot be undone.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/developer-mode:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Domains]
      operationId: enableDeveloperMode
      summary: Enable developer mode
      description: |
        Bypasses all cache reads and writes for this domain at the edge, so you
        always see the origin's current response. Auto-expires — the response
        carries the expiry — so a forgotten toggle can never permanently disable
        caching. Requires `domain.settings`.
      responses:
        "200":
          description: Developer mode enabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeveloperMode" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Domains]
      operationId: disableDeveloperMode
      summary: Disable developer mode
      description: Turns developer mode off immediately. Requires `domain.settings`.
      responses:
        "200":
          description: Developer mode disabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeveloperMode" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/enable:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Domains]
      operationId: enableDomain
      summary: Re-enable a disabled domain
      description: |
        Brings a `disabled` domain back into service. A managed domain returns to
        `pending` and is re-checked against the NSIN nameservers. Requires
        `domain.settings`.
      responses:
        "200":
          description: Domain re-enabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainDetail" }
        "400":
          description: The domain is not in the `disabled` state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/check-ns:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Domains]
      operationId: checkNameservers
      summary: Check nameserver delegation now
      description: |
        Runs an immediate delegation check for a `pending` or `moved` **managed**
        domain instead of waiting for the background checker. On success the
        domain is activated right away.

        Rate-limited to once per hour per domain, independently of the API key
        rate limit. Requires `domain.settings`.
      responses:
        "200":
          description: Check result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NsCheckResult" }
        "400":
          description: |
            Not a managed domain, or the domain is not awaiting nameserver
            changes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429":
          description: |
            Either the once-per-hour manual check limit or the API key rate
            limit was hit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /domains/{domain}/verify:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Domains]
      operationId: verifyDomain
      summary: Verify an external-DNS domain
      description: |
        Checks for the TXT record described by the domain's `verification` block
        and activates the domain when it is found. Only valid for
        `dns_mode: external`. Requires `domain.settings`.
      responses:
        "200":
          description: Verified — the domain is now active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyResult" }
        "400":
          description: Not an external-DNS domain, or not awaiting verification.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: The verification token has expired; call `verify/retry` for a fresh one.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: |
            The TXT record was not found or did not match. `verified` is `false`
            and `error` explains what was seen.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  verified: { type: boolean, const: false }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/verify/retry:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Domains]
      operationId: retryDomainVerification
      summary: Issue a fresh verification token
      description: |
        Resets a failed external-DNS verification and mints a new TXT token. Use
        the `verification` block of the response as the new record to publish.
        Requires `domain.settings`.
      responses:
        "200":
          description: Verification reset with a new token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainDetail" }
        "400":
          description: Not an external-DNS domain, or not in the failed-verification state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # SSL
  # ---------------------------------------------------------------------------

  /domains/{domain}/ssl/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [SSL]
      operationId: getSslInfo
      summary: Get certificate status
      description: |
        The domain's current certificate — issuer, validity, SANs, key size — plus
        whether a manual re-issue is currently allowed, and `coverage`: proxied
        hostnames that are **not** on the certificate yet, with their retry state.
      responses:
        "200":
          description: Certificate status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SslInfo" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [SSL]
      operationId: uploadCustomCertificate
      summary: Upload a custom certificate
      description: |
        Installs your own certificate and private key for the domain. Include the
        full chain (leaf **and** intermediates) in `certificate` — a leaf-only
        upload makes clients fail chain verification.

        `hostnames` selects which of the certificate's SANs this upload should
        cover; use the `eligible` list from `POST /domains/{domain}/ssl/parse` to
        pick them. Requires `ssl.manage` and a plan that includes custom
        certificates.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomCertificateUpload" }
      responses:
        "200":
          description: Certificate installed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomCertificateResult" }
        "400":
          description: |
            Missing fields, unparseable PEM, key/certificate mismatch, an expired
            certificate, or a hostname that the certificate does not cover.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Read-only key, insufficient role, or the plan does not include custom certificates.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/ssl/parse:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [SSL]
      operationId: parseCustomCertificate
      summary: Inspect a certificate before uploading
      description: |
        Parses a certificate PEM and reports its subject, issuer, validity and
        SANs — without installing anything. `eligible` lists the SANs that belong
        to this domain and may therefore be passed as `hostnames` to the upload
        call; `default_selection` is the subset the panel pre-selects.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [certificate]
              properties:
                certificate:
                  type: string
                  description: PEM-encoded certificate.
      responses:
        "200":
          description: Parsed certificate.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ParsedCertificate" }
        "400":
          description: Missing or unparseable certificate PEM.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/ssl/issue:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [SSL]
      operationId: issueCertificate
      summary: Request certificate issuance
      description: |
        Starts an ACME order for the domain. Allowed only when SSL is currently
        `missing` or `failed` — certificates are otherwise issued and renewed
        automatically. Not available for `dns_mode: external` domains.

        Issuance is asynchronous: this returns immediately with
        `status: "pending"`; poll `GET /domains/{domain}/ssl/` for the outcome.
        Manual attempts are rate-limited per domain — `GET /ssl/` reports
        `can_manual_issue` and `next_manual_issue_at`. Requires `ssl.manage`.
      responses:
        "200":
          description: Issuance started.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, examples: ["SSL issuance started"] }
                  status: { type: string, const: pending }
                  last_issue_attempt_at: { type: string, format: date-time }
        "400":
          description: |
            The domain uses external DNS, or SSL is not in a state where a manual
            issue is allowed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: An issuance is already in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: |
            The per-domain manual issuance cooldown has not elapsed, or the API
            key rate limit was hit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: The certificate issuer is temporarily unavailable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ---------------------------------------------------------------------------
  # DNS Records
  # ---------------------------------------------------------------------------

  /domains/{domain}/records/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [DNS Records]
      operationId: listRecords
      summary: List DNS records
      description: |
        All records of the domain, newest first.

        Proxied records may carry `origin_rules` — origin route or origin pool
        rules that override where that record's traffic actually goes, so the
        effective origin is **not** the record's `destination`. Routes are listed
        before pools, mirroring edge precedence.
      responses:
        "200":
          description: Record list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RecordWithOriginRules" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [DNS Records]
      operationId: createRecord
      summary: Create a DNS record
      description: |
        Creates one record and publishes it to the DNS zone.

        Setting `proxied: true` routes the hostname through the NSIN edge: the
        published DNS answer becomes the NSIN proxy IP and `destination` becomes
        the origin the edge connects to. Only `A`, `AAAA`, `CNAME` and `ANAME`
        can be proxied. Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecordCreate" }
      responses:
        "200":
          description: Record created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Record" }
        "400":
          description: Invalid record — bad type, malformed destination, or a value the zone rejects.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: The domain is disabled, or a conflicting record already exists.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/{recordId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RecordId"
    put:
      tags: [DNS Records]
      operationId: updateRecord
      summary: Update a DNS record
      description: |
        Partial update — omitted fields keep their current value. Requires
        `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecordUpdate" }
      responses:
        "200":
          description: Updated record.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Record" }
        "400":
          description: Invalid value, or the record is not editable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or record not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [DNS Records]
      operationId: deleteRecord
      summary: Delete a DNS record
      description: Removes the record from the zone. Requires `records.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or record not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/batch-update:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: batchUpdateRecords
      summary: Update many records at once
      description: |
        Applies an update to many records in one request. Each item accepts
        exactly the same optional fields as a single `PUT`.

        **Best-effort:** every record is processed independently, so one bad
        record does not abort the rest. The response always returns `200` with a
        per-record `results` array — check it rather than the status code.
        Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [updates]
              properties:
                updates:
                  type: array
                  items:
                    allOf:
                      - type: object
                        required: [id]
                        properties:
                          id: { type: integer, description: Id of the record to update. }
                      - $ref: "#/components/schemas/RecordUpdate"
      responses:
        "200":
          description: Per-record outcome.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BatchResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/batch-delete:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: batchDeleteRecords
      summary: Delete many records at once
      description: |
        Deletes many records in one request. Best-effort per record — see
        `batch-update`. Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items: { type: integer }
      responses:
        "200":
          description: Per-record outcome.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BatchResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/scan:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [DNS Records]
      operationId: scanRecords
      summary: Scan the domain's existing DNS from public resolvers
      description: |
        Queries public resolvers for records that already exist for this domain
        and returns them as an import preview — nothing is written. Each entry is
        marked `new`, `overwrite` (an NSIN record with the same name and type
        already exists) or `unsupported`.

        Use this to review before calling `scan-import`. Requires `records.edit`.
      responses:
        "200":
          description: Scan preview.
          content:
            application/json:
              schema:
                type: object
                properties:
                  records:
                    type: array
                    items: { $ref: "#/components/schemas/ImportPreviewRecord" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/scan-import:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: scanImportRecords
      summary: Scan and import in one step
      description: |
        Scans the domain's existing DNS from public resolvers and imports
        everything it finds, without a review step. Convenient right after adding
        a domain. Requires `records.edit`.
      responses:
        "200":
          description: Import outcome, plus the domain's full record list afterwards.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: The domain is disabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/import/parse:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: parseZoneFile
      summary: Parse a zone file into an import preview
      description: |
        Accepts a BIND-style zone file and returns what would be imported, with
        each entry marked `new`, `overwrite` or `unsupported`. Nothing is
        written — pass the entries you want to `POST .../records/import`.
        Requires `records.edit`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                  description: The zone file to parse.
          text/plain:
            schema:
              type: string
              description: Raw zone file contents.
      responses:
        "200":
          description: Import preview.
          content:
            application/json:
              schema:
                type: object
                properties:
                  records:
                    type: array
                    items: { $ref: "#/components/schemas/ImportPreviewRecord" }
        "400":
          description: The zone file could not be parsed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/import:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: importRecords
      summary: Import records
      description: |
        Creates the supplied records, overwriting any existing record with the
        same name and type. Best-effort per record — the response counts what
        succeeded and lists what failed. Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [records]
              properties:
                records:
                  type: array
                  items: { $ref: "#/components/schemas/ImportRecordItem" }
      responses:
        "200":
          description: Import outcome.
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  overwritten: { type: integer }
                  failed:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string }
                        type: { type: string }
                        error: { type: string }
        "400":
          description: Malformed request body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }


  # ---------------------------------------------------------------------------
  # Gateways
  #
  # A gateway is a ready-made record NSIN maintains: you pick one from the
  # catalog and we create the record on your domain, with a generated hostname
  # and an origin you do not have to know. The resulting record is not editable
  # — its destination and upstream Host header are ours to set — so it is
  # removed through this endpoint rather than the record delete endpoint.
  #
  # Gateways are managed-DNS only. Switching one on publishes a hostname into
  # the zone NSIN serves, so a domain whose `dns_mode` is `external` cannot have
  # one: switching on and renaming both answer 409 with
  # `error_code: external_dns`. Switching off keeps working, so a domain moved
  # to external DNS can still clear out the gateways it had.
  # ---------------------------------------------------------------------------

  /domains/{domain}/gateways/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Gateways]
      operationId: listGateways
      summary: List gateways
      description: |
        Every gateway currently offered, each with whether it is switched on for
        this domain and, when it is, the record that was created for it —
        together with this domain's gateway plan standing: whether gateways are
        included at all, and how much of the rolling 30-day request allowance
        has been used.
      responses:
        "200":
          description: Gateway list and quota standing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GatewayList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/gateways/terms:
    get:
      tags: [Gateways]
      summary: Gateway terms of use status
      description: |
        Whether this domain has accepted the gateway terms of use, and who
        accepted them. Gateways route traffic over shared egress IP addresses
        that can change, so the terms must be accepted before a gateway can be
        enabled — `POST .../apply` returns 403 with
        `error_code: gateway_terms_required` until they are.

        Acceptance is per DOMAIN, not per user.
      operationId: getGatewayTerms
      parameters:
        - $ref: "#/components/parameters/DomainName"
      responses:
        "200":
          description: Terms status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GatewayTerms" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }

  /domains/{domain}/gateways/terms/accept:
    post:
      tags: [Gateways]
      summary: Accept the gateway terms of use
      description: |
        Records acceptance for this domain. Idempotent — re-accepting keeps the
        original timestamp and accepter. Requires the same permission as
        enabling a gateway.
      operationId: acceptGatewayTerms
      parameters:
        - $ref: "#/components/parameters/DomainName"
      responses:
        "200":
          description: Terms status after acceptance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GatewayTerms" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }

  /domains/{domain}/gateways/{gatewayId}/apply:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/GatewayId"
    post:
      tags: [Gateways]
      operationId: enableGateway
      summary: Switch a gateway on
      description: |
        Creates the gateway's record on this domain and returns it. The record is
        proxied through the NSIN edge and counts against your plan's record
        limit, but it is **not editable** — updating or deleting it through the
        DNS record endpoints returns 403. Use the rename and delete endpoints
        below instead.

        Send a `name` to choose the hostname yourself; omit the body entirely and
        one is generated as `<slug>-<5 digits>`.

        A gateway can be on at most once per domain. Requires `records.edit`.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GatewayName" }
      responses:
        "201":
          description: Gateway switched on; the created record.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Record" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            Insufficient permission, no active plan, or the plan's record limit
            is already reached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Domain not found, or no such gateway is on offer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            The domain is disabled, uses external DNS (`error_code:
            external_dns`), or this gateway is already on — in that last case
            the response carries the existing record under `record`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/gateways/{gatewayId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/GatewayId"
    put:
      tags: [Gateways]
      operationId: renameGateway
      summary: Rename a gateway
      description: |
        Changes the hostname of a gateway that is already on, moving it in the
        zone. Only the name changes — the origin and Host header behind the
        gateway stay ours. This is the only way to rename the record, since the
        DNS record endpoints refuse it. Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GatewayName" }
      responses:
        "200":
          description: The renamed record.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Record" }
        "400":
          description: Missing or invalid name — not a valid hostname label, `@`, or a wildcard.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain not found, or this gateway is not on for it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            The domain is disabled, uses external DNS (`error_code:
            external_dns`), or a record with that name already exists.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Gateways]
      operationId: disableGateway
      summary: Switch a gateway off
      description: |
        Deletes the record this gateway created and removes it from the zone.
        This is the only way to remove a gateway record. Requires `records.edit`.
      responses:
        "200":
          description: Switched off.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain not found, or this gateway is not on for it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }


  # ---------------------------------------------------------------------------
  # Rules
  #
  # Every rule type exposes the same seven operations. Note that the path
  # segment is hyphenated for some types (rate-limit, bot-route, error-page)
  # and underscored for others (origin_pool, origin_route).
  # ---------------------------------------------------------------------------

  /domains/{domain}/rules/cache/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listCacheRules
      summary: List cache rules
      description: |
        Decides what the edge caches, for how long, and which safety bypasses apply. A domain may hold several cache rules with different tradeoffs; each is self-contained.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Cache rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CacheRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createCacheRule
      summary: Create a cache rule
      description: |
        Decides what the edge caches, for how long, and which safety bypasses apply. A domain may hold several cache rules with different tradeoffs; each is self-contained.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CacheRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/cache/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderCacheRules
      summary: Reorder cache rules
      description: |
        Sets the `priority` of several cache rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/cache/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getCacheRule
      summary: Get a cache rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateCacheRule
      summary: Update a cache rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CacheRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteCacheRule
      summary: Delete a cache rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/cache/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleCacheRule
      summary: Enable or disable a cache rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/drop/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listDropRules
      summary: List drop rules
      description: |
        Blocks matching requests at the edge, optionally restricted by visitor country.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Drop rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/DropRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createDropRule
      summary: Create a drop rule
      description: |
        Blocks matching requests at the edge, optionally restricted by visitor country.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DropRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DropRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/drop/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderDropRules
      summary: Reorder drop rules
      description: |
        Sets the `priority` of several drop rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/drop/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getDropRule
      summary: Get a drop rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DropRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateDropRule
      summary: Update a drop rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DropRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DropRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteDropRule
      summary: Delete a drop rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/drop/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleDropRule
      summary: Enable or disable a drop rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DropRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/redirect/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listRedirectRules
      summary: List redirect rules
      description: |
        Returns an HTTP redirect for matching requests instead of proxying them to the origin.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Redirect rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RedirectRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createRedirectRule
      summary: Create a redirect rule
      description: |
        Returns an HTTP redirect for matching requests instead of proxying them to the origin.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RedirectRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/redirect/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderRedirectRules
      summary: Reorder redirect rules
      description: |
        Sets the `priority` of several redirect rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/redirect/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getRedirectRule
      summary: Get a redirect rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateRedirectRule
      summary: Update a redirect rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RedirectRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteRedirectRule
      summary: Delete a redirect rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/redirect/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleRedirectRule
      summary: Enable or disable a redirect rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rewrite/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listRewriteRules
      summary: List rewrite rules
      description: |
        Rewrites the path and/or query string before the request is sent to the origin. The visitor's URL is unchanged.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Rewrite rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RewriteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createRewriteRule
      summary: Create a rewrite rule
      description: |
        Rewrites the path and/or query string before the request is sent to the origin. The visitor's URL is unchanged.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RewriteRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RewriteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rewrite/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderRewriteRules
      summary: Reorder rewrite rules
      description: |
        Sets the `priority` of several rewrite rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rewrite/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getRewriteRule
      summary: Get a rewrite rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RewriteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateRewriteRule
      summary: Update a rewrite rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RewriteRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RewriteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteRewriteRule
      summary: Delete a rewrite rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rewrite/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleRewriteRule
      summary: Enable or disable a rewrite rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RewriteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/optimize/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listOptimizeRules
      summary: List web optimization rules
      description: |
        Shrinks matching responses at the edge: converts JPEG/PNG images to
        WebP, minifies JavaScript and CSS, and sets the brotli level used for
        cached text.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Optimization rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/OptimizeRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createOptimizeRule
      summary: Create a web optimization rule
      description: |
        At least one action must be enabled (`images`, `minify_js`,
        `minify_css`, or a non-zero `compress_level`); a rule that does nothing
        is rejected rather than left to shadow later rules.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OptimizeRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OptimizeRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/optimize/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderOptimizeRules
      summary: Reorder web optimization rules
      description: |
        Sets the `priority` of several optimization rules at once. Lower
        priority values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/optimize/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getOptimizeRule
      summary: Get a web optimization rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OptimizeRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateOptimizeRule
      summary: Update a web optimization rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OptimizeRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OptimizeRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteOptimizeRule
      summary: Delete a web optimization rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/optimize/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleOptimizeRule
      summary: Enable or disable a web optimization rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OptimizeRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/waf/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listWafRules
      summary: List WAF rules
      description: |
        Runs the OWASP Core Rule Set against matching requests at the chosen paranoia level and blocks once the anomaly score passes the threshold.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: WAF rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/WafRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createWafRule
      summary: Create a WAF rule
      description: |
        Runs the OWASP Core Rule Set against matching requests at the chosen paranoia level and blocks once the anomaly score passes the threshold.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WafRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WafRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/waf/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderWafRules
      summary: Reorder WAF rules
      description: |
        Sets the `priority` of several WAF rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/waf/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getWafRule
      summary: Get a WAF rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WafRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateWafRule
      summary: Update a WAF rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WafRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WafRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteWafRule
      summary: Delete a WAF rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/waf/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleWafRule
      summary: Enable or disable a WAF rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WafRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/captcha/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listCaptchaRules
      summary: List captcha rules
      description: |
        Challenges visitors on matching paths before letting them through. A solved challenge is remembered for `ttl_sec`.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Captcha rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CaptchaRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createCaptchaRule
      summary: Create a captcha rule
      description: |
        Challenges visitors on matching paths before letting them through. A solved challenge is remembered for `ttl_sec`.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CaptchaRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CaptchaRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/captcha/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderCaptchaRules
      summary: Reorder captcha rules
      description: |
        Sets the `priority` of several captcha rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/captcha/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getCaptchaRule
      summary: Get a captcha rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CaptchaRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateCaptchaRule
      summary: Update a captcha rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CaptchaRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CaptchaRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteCaptchaRule
      summary: Delete a captcha rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/captcha/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleCaptchaRule
      summary: Enable or disable a captcha rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CaptchaRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/basic_auth/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listBasicAuthRules
      summary: List basic auth rules
      description: |
        Puts an HTTP Basic sign-in prompt in front of the matching paths. A
        request without accepted credentials is answered `401` at the edge and
        never reaches your origin.

        Passwords are write-only: `users` comes back as usernames plus a
        `has_password` flag, never the password or its hash.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Basic auth rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/BasicAuthRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createBasicAuthRule
      summary: Create a basic auth rule
      description: |
        Protects the matching paths with the supplied username/password pairs.
        At least one user is required, and every user needs a password of 8–128
        characters.

        Responses on protected paths are never cached at the edge.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BasicAuthRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BasicAuthRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/basic_auth/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderBasicAuthRules
      summary: Reorder basic auth rules
      description: |
        Sets the `priority` of several basic auth rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/basic_auth/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getBasicAuthRule
      summary: Get a basic auth rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BasicAuthRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateBasicAuthRule
      summary: Update a basic auth rule
      description: |
        Partial update — omitted fields keep their current value.

        `users` is the exception: when present it replaces the whole list, so a
        username you leave out is removed. Within it, an entry whose `password`
        is omitted keeps the password that username already has, which is how
        you rename or remove users without retyping everyone's credentials.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BasicAuthRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BasicAuthRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteBasicAuthRule
      summary: Delete a basic auth rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/basic_auth/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleBasicAuthRule
      summary: Enable or disable a basic auth rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Disabling a
        rule removes the sign-in prompt from the paths it covered. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BasicAuthRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rate-limit/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listRateLimitRules
      summary: List rate limit rules
      description: |
        Counts requests per key over a sliding window and drops or challenges the ones above the limit.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Rate limit rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RateLimitRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createRateLimitRule
      summary: Create a rate limit rule
      description: |
        Counts requests per key over a sliding window and drops or challenges the ones above the limit.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RateLimitRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLimitRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rate-limit/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderRateLimitRules
      summary: Reorder rate limit rules
      description: |
        Sets the `priority` of several rate limit rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rate-limit/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getRateLimitRule
      summary: Get a rate limit rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLimitRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateRateLimitRule
      summary: Update a rate limit rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RateLimitRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLimitRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteRateLimitRule
      summary: Delete a rate limit rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rate-limit/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleRateLimitRule
      summary: Enable or disable a rate limit rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLimitRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/bot-route/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listBotRouteRules
      summary: List bot route rules
      description: |
        Acts on classified bot traffic — block it, serve alternative content, send it to a different origin, or just tag it in telemetry.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Bot route rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/BotRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createBotRouteRule
      summary: Create a bot route rule
      description: |
        Acts on classified bot traffic — block it, serve alternative content, send it to a different origin, or just tag it in telemetry.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BotRouteRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BotRouteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/bot-route/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderBotRouteRules
      summary: Reorder bot route rules
      description: |
        Sets the `priority` of several bot route rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/bot-route/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getBotRouteRule
      summary: Get a bot route rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BotRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateBotRouteRule
      summary: Update a bot route rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BotRouteRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BotRouteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteBotRouteRule
      summary: Delete a bot route rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/bot-route/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleBotRouteRule
      summary: Enable or disable a bot route rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BotRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_pool/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listOriginPoolRules
      summary: List origin pool rules
      description: |
        Load-balances matching traffic across several origins with optional health checking. Overrides the DNS record's own destination for every path it matches.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Origin pool rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/OriginPoolRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createOriginPoolRule
      summary: Create a origin pool rule
      description: |
        Load-balances matching traffic across several origins with optional health checking. Overrides the DNS record's own destination for every path it matches.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OriginPoolRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginPoolRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_pool/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderOriginPoolRules
      summary: Reorder origin pool rules
      description: |
        Sets the `priority` of several origin pool rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_pool/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getOriginPoolRule
      summary: Get a origin pool rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginPoolRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateOriginPoolRule
      summary: Update a origin pool rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OriginPoolRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginPoolRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteOriginPoolRule
      summary: Delete a origin pool rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_pool/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleOriginPoolRule
      summary: Enable or disable a origin pool rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginPoolRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_route/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listOriginRouteRules
      summary: List origin route rules
      description: |
        Sends matching paths to a different origin than the DNS record's destination. Takes precedence over origin pools.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Origin route rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/OriginRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createOriginRouteRule
      summary: Create a origin route rule
      description: |
        Sends matching paths to a different origin than the DNS record's destination. Takes precedence over origin pools.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OriginRouteRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginRouteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_route/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderOriginRouteRules
      summary: Reorder origin route rules
      description: |
        Sets the `priority` of several origin route rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_route/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getOriginRouteRule
      summary: Get a origin route rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateOriginRouteRule
      summary: Update a origin route rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OriginRouteRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginRouteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteOriginRouteRule
      summary: Delete a origin route rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_route/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleOriginRouteRule
      summary: Enable or disable a origin route rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/fingerprint/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listFingerprintRules
      summary: List fingerprint rules
      description: |
        Matches requests on their TLS/HTTP fingerprint (JA4, JA4H) and drops, challenges or tags them.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Fingerprint rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/FingerprintRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createFingerprintRule
      summary: Create a fingerprint rule
      description: |
        Matches requests on their TLS/HTTP fingerprint (JA4, JA4H) and drops, challenges or tags them.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FingerprintRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FingerprintRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/fingerprint/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderFingerprintRules
      summary: Reorder fingerprint rules
      description: |
        Sets the `priority` of several fingerprint rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/fingerprint/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getFingerprintRule
      summary: Get a fingerprint rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FingerprintRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateFingerprintRule
      summary: Update a fingerprint rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FingerprintRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FingerprintRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteFingerprintRule
      summary: Delete a fingerprint rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/fingerprint/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleFingerprintRule
      summary: Enable or disable a fingerprint rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FingerprintRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/error-page/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listErrorPageRules
      summary: List error page rules
      description: |
        Controls what visitors see for selected status codes — the NSIN branded page, your own HTML, or the origin's own response passed through untouched.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Error page rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ErrorPageRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createErrorPageRule
      summary: Create a error page rule
      description: |
        Controls what visitors see for selected status codes — the NSIN branded page, your own HTML, or the origin's own response passed through untouched.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ErrorPageRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorPageRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/error-page/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderErrorPageRules
      summary: Reorder error page rules
      description: |
        Sets the `priority` of several error page rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/error-page/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getErrorPageRule
      summary: Get a error page rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorPageRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateErrorPageRule
      summary: Update a error page rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ErrorPageRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorPageRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteErrorPageRule
      summary: Delete a error page rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/error-page/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleErrorPageRule
      summary: Enable or disable a error page rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorPageRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }


  # ---------------------------------------------------------------------------
  # Analytics
  #
  # Analytics is served from a request-log store, so figures for the last minute
  # or two may still be settling.
  #
  # The per-domain sections all take `?domain=` (the domain NAME) and share the
  # `period`, `hostname` and `path` filters. Most require a plan that includes
  # the `monitoring` feature; the raw-log endpoints require `logs`.
  # ---------------------------------------------------------------------------

  /analytics/overview:
    get:
      tags: [Analytics]
      operationId: analyticsOverview
      summary: Per-domain totals across your account
      description: |
        One row per domain you can access, with request, bandwidth and visitor
        totals for the period. Account-wide — takes no `domain` parameter.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: One entry per domain.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/OverviewItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/domains-overview:
    get:
      tags: [Analytics]
      operationId: analyticsDomainsOverview
      summary: Per-domain totals with sparkline
      description: |
        Like `/analytics/overview`, plus an error rate, a small
        requests-over-time series for sparklines, and the most recent log
        timestamp seen for each domain.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: One entry per domain.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/DomainsOverviewItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/global-summary:
    get:
      tags: [Analytics]
      operationId: analyticsGlobalSummary
      summary: Account-wide summary
      description: Headline figures aggregated across every domain you can access.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: Account-wide totals.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GlobalSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/bandwidth-overview:
    get:
      tags: [Analytics]
      operationId: analyticsBandwidthOverview
      summary: Origin-direction bandwidth across your domains
      description: |
        Bytes sent to and received from origins, as a time series plus per-domain
        totals.

        `ratio` is `min(up,down) / max(up,down)`. A value near `1.0` means the
        domain pushes about as much to the origin as it pulls back, which is
        unusual for web traffic (downloads normally dominate) and sets
        `flagged`.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: Bandwidth series and per-domain totals.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginBandwidthResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/tunnel-suspects:
    get:
      tags: [Analytics]
      operationId: analyticsTunnelSuspects
      summary: Clients whose traffic resembles a proxy tunnel
      description: |
        Clients whose WebSocket/gRPC traffic looks like a VPN or proxy tunnel run
        behind the CDN: sustained volume over a single fixed path, with opaque
        payloads and no sign of ordinary browsing (no real assets fetched, no
        referer).

        This is a heuristic for investigation, not proof of abuse. `balance` is
        informational — tunnels used for browsing are download-heavy, so
        symmetry is **not** a criterion.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: Suspected tunnel clients.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TunnelSuspectsResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/nodes:
    get:
      tags: [Analytics]
      operationId: analyticsListNodes
      summary: List edge nodes
      description: |
        Active edge nodes (points of presence). Use `name` as the `node` filter
        on `/analytics/traffic-by-node` and `/analytics/origins`.
      responses:
        "200":
          description: Edge nodes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string, description: Node identifier used in filters. }
                        label: { type: string, description: Human-readable name. }
                        country: { type: string, description: ISO country code. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /analytics/nodes-overview:
    get:
      tags: [Analytics]
      operationId: analyticsNodesOverview
      summary: Traffic by edge node, across your domains
      description: |
        Per-node totals, error and cache rates, latency, a requests-over-time
        series, and the busiest domains on each node — aggregated over every
        domain you can see, or one domain with `domain_id`.

        This is the account-wide counterpart of `/analytics/traffic-by-node`,
        which covers a single domain and splits by cache status instead.

        Two kinds of row need care when reading the list:

        * `node: ""` — requests the serving edge did not stamp with a node name.
          They are counted so the per-node rows still add up to `totals`, but
          they cannot be attributed to a point of presence.
        * `requests: 0` with `registered: true` — a node that is in service but
          served nothing in the period. Kept in the list so a node that stopped
          reporting is visible rather than silently absent.

        `error_rate` and `cache_hit_rate` are percentages (0–100). Durations are
        in milliseconds and exclude WebSocket requests, whose lifetime is the
        whole upgraded connection.
      parameters:
        - $ref: "#/components/parameters/Period"
        - name: domain_id
          in: query
          description: |
            Numeric domain id — note this endpoint scopes by **id**, not by the
            domain name the rest of the API uses. Omit to cover every active
            domain you can see.
          schema: { type: integer }
      responses:
        "200":
          description: Per-node totals with the scope-wide total they add up to.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NodesOverviewResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/node-countries:
    get:
      tags: [Analytics]
      operationId: analyticsNodeCountries
      summary: List edge node countries
      description: The distinct countries edge nodes are located in.
      responses:
        "200":
          description: Country codes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /analytics/summary:
    get:
      tags: [Analytics]
      operationId: analyticsSummary
      summary: Traffic summary for one domain
      description: |
        Headline figures for the domain over the period: requests, bandwidth,
        unique visitors, error rate and latency percentiles.

        Unique visitors are counted as distinct (client IP, JA4 TLS
        fingerprint) pairs, which separates people sharing one NAT address by
        device. On plain HTTP there is no JA4, so it degrades to counting IPs.
        Latency figures exclude WebSocket requests, whose duration spans the
        whole connection.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Summary figures.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalyticsSummary" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/requests:
    get:
      tags: [Analytics]
      operationId: analyticsRequests
      summary: Requests over time
      description: |
        Request counts bucketed by hour (periods up to 24h) or by day (`7d`,
        `30d`).
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RequestsDataPoint" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/requests-compare:
    get:
      tags: [Analytics]
      operationId: analyticsRequestsCompare
      summary: Requests over time, for period-over-period comparison
      description: |
        Request counts in whole-day windows, for charts that overlay one period
        on another. Unlike `/analytics/requests` — which ends *now* and takes a
        `period` — this window always starts at a local midnight, so every
        bucket covers a complete day and days can be compared like for like.

        * `granularity: hour` returns hourly buckets, meant to be drawn as one
          line per day (hour-by-hour overlay). `days` defaults to 3, max 14.
        * `granularity: day` returns one bucket per day for day-over-day change.
          `days` defaults to 14, max 35 — the ClickHouse row retention, beyond
          which no data exists.

        Scope is one domain (`domain_id`), or — by default — every active domain
        you can see. The response is the same time-series shape as
        `/analytics/requests`; buckets with no traffic are omitted rather than
        zero-filled.
      parameters:
        - name: granularity
          in: query
          description: Bucket size. Anything other than `day` is treated as `hour`.
          schema: { type: string, enum: [hour, day], default: hour }
        - name: days
          in: query
          description: |
            Number of whole days to return, counting back from today. Clamped to
            the granularity's maximum. `0` or omitted uses the default.
          schema: { type: integer, minimum: 1, maximum: 35 }
        - name: domain_id
          in: query
          description: |
            Numeric domain id — note this endpoint scopes by **id**, not by the
            domain name the rest of the API uses. Omit to cover every active
            domain you can see.
          schema: { type: integer }
      responses:
        "200":
          description: Time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RequestsDataPoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/visitors:
    get:
      tags: [Analytics]
      operationId: analyticsVisitors
      summary: Unique visitors over time
      description: |
        Distinct visitors per bucket, counted as (client IP, JA4) pairs. Note
        that visitors do not sum across buckets — the same person appears in
        every bucket they were active in.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RequestsDataPoint" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/bandwidth:
    get:
      tags: [Analytics]
      operationId: analyticsBandwidth
      summary: Bandwidth over time
      description: Bytes in and out per bucket, from the visitor's perspective.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BandwidthDataPoint" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/top-uris:
    get:
      tags: [Analytics]
      operationId: analyticsTopUris
      summary: Most requested URIs
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Top URIs by request count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TopUri" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/top-requests:
    get:
      tags: [Analytics]
      operationId: analyticsTopRequests
      summary: Top-N breakdown by a chosen metric
      description: |
        A ranked breakdown of the domain's traffic. `metric` selects what is
        ranked, and which fields of each row are populated — rows omit the
        fields that do not apply.

        Requires a plan including the `logs` feature.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - name: metric
          in: query
          required: true
          description: |
            * `slow_requests` — slowest paths, with average and maximum duration. Excludes WebSockets.
            * `uris` — most requested paths.
            * `errors_5xx` — paths returning server errors.
            * `hosts` — busiest subdomains.
            * `countries` — busiest visitor countries.
            * `user_agents` — busiest user agents.
            * `networks` — busiest visitor networks, keyed `AS<number>` with the operator in `label`.
          schema:
            type: string
            enum: [slow_requests, uris, errors_5xx, hosts, countries, user_agents, networks]
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Ranked rows.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TopRequestRow" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/countries:
    get:
      tags: [Analytics]
      operationId: analyticsCountries
      summary: Traffic by visitor country
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-country totals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CountryStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/asns:
    get:
      tags: [Analytics]
      operationId: analyticsAsns
      summary: Traffic by visitor network (ASN)
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-network totals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/AsnStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/protocols:
    get:
      tags: [Analytics]
      operationId: analyticsProtocols
      summary: Traffic by HTTP protocol version
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-protocol request counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ProtocolStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/tls:
    get:
      tags: [Analytics]
      operationId: analyticsTls
      summary: TLS versions, cipher suites and session resumption
      description: |
        What your visitors negotiate with the edge. Every count here is
        restricted to TLS-terminated requests, so plain-HTTP traffic never
        enters the totals — a domain redirecting `:80` to `:443` does not read
        as though a slice of its visitors used no TLS at all.

        `pct` in `versions` is a share of all TLS requests. `pct` in `ciphers`
        is a share of the returned suites only: the list is capped at the top
        12, and the shares are normalised over that list so they still add up
        to 100%.

        This is the visitor-to-edge leg only. The edge-to-origin handshake is
        not reported here.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: TLS mix for the period.
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary: { $ref: "#/components/schemas/TlsSummary" }
                  versions:
                    type: array
                    items: { $ref: "#/components/schemas/TlsVersionStats" }
                  ciphers:
                    type: array
                    description: Top 12 cipher suites, most used first.
                    items: { $ref: "#/components/schemas/TlsCipherStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/ai-crawlers:
    get:
      tags: [Analytics]
      operationId: analyticsAiCrawlers
      summary: AI crawler traffic, in full
      description: |
        Everything the AI Crawl Control pages are built from, in one response:
        period counters, a timeseries, a per-crawler table, and the paths
        crawlers read or were refused.

        `summary`, `series`, `top_paths` and `blocked_paths` cover AI crawlers
        only — the kinds listed in `ai_kinds`, or the single kind named by
        `crawler`. Classic search and SEO crawlers (`googlebot`, `bingbot`,
        `yandexbot`, `ahrefsbot`, `semrushbot`, `mj12bot`, `generic-bot`) are
        deliberately left out of those, so they cannot drown the AI numbers.
        `crawlers` is the exception: it lists **every** bot kind actually seen
        on the domain, so nothing is invisible.

        Human traffic never reaches any of these numbers.

        The Markdown counters in `summary` describe the Markdown-for-Agents
        feature: `markdown_answered` is what the edge actually rewrote to
        Markdown, and `markdown_missed` is the rest of what could plausibly
        have been Markdown (`markdown_eligible` — responses below `300`).
        Redirects, `404`s and images are not counted against the feature.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - name: crawler
          in: query
          description: |
            Narrow every panel except `crawlers` to one bot kind, from the
            canonical catalog — the values in `ai_kinds`, plus `googlebot`,
            `bingbot`, `duckduckbot`, `yandexbot`, `ahrefsbot`, `semrushbot`,
            `mj12bot` and `generic-bot`. Omit for all AI crawlers.
          schema: { type: string }
          example: gptbot
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: AI crawler activity for the period.
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary: { $ref: "#/components/schemas/AiCrawlerSummary" }
                  crawlers:
                    type: array
                    description: Every bot kind seen, busiest first — not limited to AI crawlers.
                    items: { $ref: "#/components/schemas/AiCrawlerStats" }
                  series:
                    type: array
                    items: { $ref: "#/components/schemas/AiCrawlerDataPoint" }
                  top_paths:
                    type: array
                    description: Top 10 paths AI crawlers read successfully.
                    items: { $ref: "#/components/schemas/AiCrawlerPath" }
                  blocked_paths:
                    type: array
                    description: |
                      Top 10 paths AI crawlers asked for and did not get (`4xx`
                      or `5xx`) — the content agents want but cannot cite.
                    items: { $ref: "#/components/schemas/AiCrawlerPath" }
                  ai_kinds:
                    type: array
                    description: The bot kinds counted as AI crawler traffic.
                    items: { type: string }
        "400":
          description: The `domain` query parameter is missing, or `crawler` is not a known bot kind.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                unknownCrawler:
                  value: { error: "unknown crawler" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/status-codes:
    get:
      tags: [Analytics]
      operationId: analyticsStatusCodes
      summary: Traffic by HTTP status code
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-status-code counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/StatusCodeStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/unreachable-reasons:
    get:
      tags: [Analytics]
      operationId: analyticsUnreachableReasons
      summary: Why requests could not be served
      description: |
        A breakdown of failed requests by cause, with plain-language
        explanations and who is responsible (`client`, `origin`, `network` or
        `config`) — so you can tell a visitor hanging up from your server
        crashing without reading raw proxy errors.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Failure reasons.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/UnreachableReason" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/cache:
    get:
      tags: [Analytics]
      operationId: analyticsCache
      summary: Cache hit, miss and bypass counts
      description: |
        `hit_rate` is `hits / (hits + misses)` — bypasses are excluded from the
        denominator, since a bypassed request was never a caching candidate.
        `bypass_reasons` breaks down why requests bypassed the cache.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Cache counters.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheAnalytics" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/traffic-by-cache:
    get:
      tags: [Analytics]
      operationId: analyticsTrafficByCache
      summary: Egress bytes by cache status over time
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TrafficByCacheDataPoint" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/traffic-by-reqstatus:
    get:
      tags: [Analytics]
      operationId: analyticsTrafficByReqStatus
      summary: Egress bytes by serving path over time
      description: |
        Serving path is orthogonal to cache status: `cache` went through the
        caching pipeline, `proxied` reached the origin through the edge proxy,
        and `direct` reached the origin without it.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TrafficByReqStatusDataPoint" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/traffic-by-node:
    get:
      tags: [Analytics]
      operationId: analyticsTrafficByNode
      summary: Traffic by edge node
      description: Requests and egress bytes per edge node, split by cache status.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/NodeFilter"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-node totals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TrafficByNodeStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/origins:
    get:
      tags: [Analytics]
      operationId: analyticsOrigins
      summary: Edge-to-origin request statistics
      description: |
        How each of your origin addresses is performing as seen from the edge —
        request counts, failures, server errors and upstream latency — with the
        per-node split that produced them.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/NodeFilter"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-origin statistics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/OriginStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/user-agents:
    get:
      tags: [Analytics]
      operationId: analyticsUserAgents
      summary: Traffic by user-agent category
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-category request counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/UserAgentCategoryStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/gateways:
    get:
      tags: [Analytics]
      operationId: analyticsGateways
      summary: Requests per gateway
      description: |
        Request counts for each gateway currently switched on for the domain,
        with a zero-filled series over the period — one point per bucket whether
        or not traffic landed in it.

        Which hostnames count as gateways is resolved server-side from the
        domain's records, so a gateway switched off drops out of this response
        along with its history.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: One entry per enabled gateway. Empty when none are on.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/GatewayStat" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /analytics/logs:
    get:
      tags: [Analytics]
      operationId: analyticsLogs
      summary: Raw request logs
      description: |
        Individual request records, newest first, with every filter applied as
        an AND. Requires a plan including the `logs` feature.

        Header and body fields are retained for a shorter window than the rest
        of the row, so older entries return them empty.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - name: limit
          in: query
          description: Rows per page, 1–500. Values outside the range fall back to 100.
          schema: { type: integer, default: 100, minimum: 1, maximum: 500 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
        - { name: status, in: query, description: Exact HTTP status code., schema: { type: string } }
        - { name: method, in: query, description: HTTP method — case-insensitive., schema: { type: string } }
        - { name: uri, in: query, description: URI substring match., schema: { type: string } }
        - { name: cache, in: query, description: "Cache status: `hit`, `miss` or `bypass`.", schema: { type: string, enum: [hit, miss, bypass] } }
        - { name: reqStatus, in: query, description: "Serving path: `cache`, `proxied` or `direct`.", schema: { type: string, enum: [cache, proxied, direct] } }
        - { name: rayId, in: query, description: Exact ray id of a single request., schema: { type: string } }
        - name: "hostname"
          in: query
          description: "Exact host, a subdomain of it, or a bare subdomain label."
          schema: { type: string }
        - { name: originHost, in: query, description: Host header sent to the origin., schema: { type: string } }
        - { name: originSni, in: query, description: SNI presented to the origin., schema: { type: string } }
        - { name: originAddr, in: query, description: Origin address the edge connected to., schema: { type: string } }
        - { name: originAddrs, in: query, description: Comma-separated list of origin addresses., schema: { type: string } }
        - { name: remoteAddr, in: query, description: Client IP address., schema: { type: string } }
        - { name: country, in: query, description: Client ISO country code., schema: { type: string } }
        - { name: nodeCountry, in: query, description: ISO country of the edge node that served the request., schema: { type: string } }
        - { name: node, in: query, description: Edge node name., schema: { type: string } }
        - { name: threat, in: query, description: Threat category., schema: { type: string } }
        - { name: detectAction, in: query, description: Action a detection rule took on the request., schema: { type: string } }
        - { name: botKind, in: query, description: Classified bot kind., schema: { type: string } }
        - { name: wafRuleId, in: query, description: A CRS rule id that fired., schema: { type: string } }
        - { name: headerSearch, in: query, description: Substring searched across the captured headers., schema: { type: string } }
        - { name: uriPatterns, in: query, description: Comma-separated URI patterns., schema: { type: string } }
        - { name: path, in: query, description: URL path prefix., schema: { type: string } }
      responses:
        "200":
          description: A page of request logs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LogsResponse" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/waf-logs:
    get:
      tags: [Analytics]
      operationId: analyticsWafLogs
      summary: WAF event logs
      description: |
        Requests the WAF evaluated, with the rules that fired and the score they
        produced. Entries where `dryRun` is true were logged only — the request
        was not actually blocked.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - name: limit
          in: query
          schema: { type: integer, default: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
        - { name: hostname, in: query, description: Substring match on hostname., schema: { type: string } }
        - { name: action, in: query, description: The action taken., schema: { type: string } }
        - { name: ruleId, in: query, description: A CRS rule id that fired., schema: { type: string } }
        - { name: clientIp, in: query, schema: { type: string } }
        - { name: country, in: query, schema: { type: string } }
        - { name: rayId, in: query, schema: { type: string } }
        - { name: blocked, in: query, description: Restrict to blocked or non-blocked requests., schema: { type: boolean } }
      responses:
        "200":
          description: A page of WAF events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WafLogEntry" }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/markdown-tester:
    get:
      tags: [Analytics]
      operationId: analyticsMarkdownTester
      summary: Preview Markdown-for-Agents conversion
      description: |
        Fetches one page twice — once normally and once with
        `Accept: text/markdown` — and returns both responses so you can compare
        them. Ownership is checked but there is no plan gate: you may preview
        the conversion before enabling `markdown_for_agents` on the domain.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - name: hostname
          in: query
          description: Which hostname to fetch. Defaults to the domain apex.
          schema: { type: string }
        - name: path
          in: query
          description: Path to fetch.
          schema: { type: string, default: "/" }
      responses:
        "200":
          description: Both fetches, side by side.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MarkdownTesterResult" }
        "400":
          description: Missing `domain`, or an invalid hostname or path.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /analytics/query:
    post:
      tags: [Analytics]
      operationId: analyticsQuery
      summary: Run a custom query over your request logs
      description: |
        Runs a read-only SQL `SELECT` against the `requests` table — your raw
        request log — for analyses the dedicated endpoints do not cover.

        **Scoping is enforced by the database engine**, not by your query: a
        filter restricting rows to the domains this key can access is appended
        to every read of `requests`. You cannot read another account's traffic,
        however the query is written.

        Restrictions:

        * A single statement only, starting with `SELECT` or `WITH`.
        * Only the `requests` table may be read. Common table expressions you
          define yourself are fine; other tables and any `db.table` reference
          are rejected.
        * Writes, DDL and settings changes are rejected.
        * Execution is capped at 30 seconds and 10 000 returned rows —
          `truncated` tells you when the cap was hit.

        Useful `requests` columns: `event_time`, `domain_id`, `hostname`,
        `method`, `uri`, `status`, `bytesIn`, `bytesOut`, `duration` (ms),
        `remoteAddr`, `country`, `asn`, `asnOrg`, `userAgent`, `cacheStatus`,
        `reqStatus`, `isWS`, `protocol`, `referer`, `originStatus`,
        `originAddr`, `error`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sql]
              properties:
                sql:
                  type: string
                  description: The query to run.
                  examples:
                    - "SELECT toStartOfHour(event_time) AS h, count() AS c FROM requests WHERE status >= 500 GROUP BY h ORDER BY h"
      responses:
        "200":
          description: Query result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalyticsQueryResult" }
        "400":
          description: |
            The query was rejected by validation, or the database refused it.
            `detail` carries the underlying message when the engine rejected it.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  detail: { type: string }
              examples:
                disallowedTable:
                  value:
                    error: "querying \"system.parts\" is not allowed; only the 'requests' table may be read"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            Read-only key, or the account has no domains whose logs could be
            queried.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  # ---------------------------------------------------------------------------
  # Uptime
  # ---------------------------------------------------------------------------

  /uptime:
    get:
      tags: [Uptime]
      operationId: listOutageIncidents
      summary: List outage incidents
      description: |
        The domain's sustained origin-outage incidents, most recent first. An
        incident opens when a subdomain's origin-error rate stays above the
        domain's threshold for the whole detection window, and resolves after
        `recover_min` clear minutes.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Incident history.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/OutageIncident" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /uptime/active:
    get:
      tags: [Uptime]
      operationId: getActiveOutages
      summary: Outage incidents open right now
      description: |
        A one-glance answer to "is anything down?" — the incidents currently
        open for this domain. Cheaper than `/uptime/live` (it reads only the
        incident records, no traffic aggregation), so it is the endpoint to poll
        for a status indicator.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Active outages.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeActive" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /uptime/live:
    get:
      tags: [Uptime]
      operationId: getUptimeLive
      summary: Current origin-error status per subdomain
      description: |
        What is happening right now, per subdomain, over the domain's detection
        window — including hosts that are erroring but have not (yet) crossed
        the alert thresholds. Distinct from `/uptime`, which lists only
        sustained outages.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Live status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeLive" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /uptime/settings:
    get:
      tags: [Uptime]
      operationId: getUptimeSettings
      summary: Get outage-detection settings
      description: |
        The domain's detection thresholds, with the valid range for each in
        `bounds`.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Current settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeSettings" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Uptime]
      operationId: updateUptimeSettings
      summary: Update outage-detection settings
      description: |
        Partial update — omitted fields keep their current value. Values are
        clamped to the ranges reported in `bounds`. Requires `domain.settings`.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UptimeSettingsUpdate" }
      responses:
        "200":
          description: Updated settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeSettings" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Recommendations
  # ---------------------------------------------------------------------------

  /recommendations:
    get:
      tags: [Recommendations]
      operationId: listRecommendations
      summary: Get the domain's advisory checklist
      description: |
        Per-domain advice derived from analytics, configuration and live probes.
        Items with `status: ok` are passing checks; `warn` items suggest an
        action. Dismissed items are still returned, flagged `dismissed: true`.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Checklist items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Recommendation" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /recommendations/count:
    get:
      tags: [Recommendations]
      operationId: countRecommendations
      summary: Count outstanding recommendations
      description: How many items need action — excluding dismissed and passing ones.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Outstanding count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /recommendations/dismiss:
    post:
      tags: [Recommendations]
      operationId: dismissRecommendation
      summary: Dismiss a recommendation
      description: |
        Hides one checklist item for the calling user on this domain. Dismissals
        are per user, not per domain — they do not affect other members.
        Repeating the call is a no-op.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                key:
                  type: string
                  description: The recommendation's `key`.
      responses:
        "200":
          description: Dismissed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400":
          description: Missing `domain`, or missing `key` in the body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Recommendations]
      operationId: undismissRecommendation
      summary: Restore a dismissed recommendation
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                key: { type: string }
      responses:
        "200":
          description: Restored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400":
          description: Missing `domain`, or missing `key` in the body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Cache
  # ---------------------------------------------------------------------------

  /cache/stats/:
    get:
      tags: [Cache]
      operationId: getCacheStats
      summary: Cached entry count and size for a domain
      description: |
        The domain's live cache footprint, summed across every storage node.
        Results are cached briefly, so a purge can take a few seconds to show
        up here. Returns zeroes when the cache layer is not enabled.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Cache footprint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries: { type: integer }
                  size_bytes: { type: integer }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/cache/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    delete:
      tags: [Cache]
      operationId: purgeDomainCache
      summary: Purge the entire cache for a domain
      description: |
        Removes every cached entry for the domain.

        The sweep runs in the background: a `202` means it was queued and
        `deleted` is not yet known. Requires `cache.edit` and a plan including
        cache purge.
      responses:
        "200":
          description: Purge completed synchronously — nothing was cached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurgeResult" }
        "202":
          description: Purge queued; it runs in the background.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurgeResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Read-only key, insufficient role, or cache purge is not on the plan.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: The domain is disabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/cache/keys:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Cache]
      operationId: listCacheKeys
      summary: Browse cached entries
      description: |
        A page of the domain's individual cached objects.

        Note the two host/path pairs on each row: `host` and `path` are the
        human-readable request URL, while `hostname` (the storage namespace) and
        `store_path` are the stored identity you must echo back when purging a
        specific row. Requires `domain.view`.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 100, minimum: 1, maximum: 500 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
        - name: sort
          in: query
          description: Sort column. Anything else falls back to `cached_at`.
          schema: { type: string, enum: [size, host, hostname, path, expires_at, cached_at] }
        - name: dir
          in: query
          schema: { type: string, enum: [asc, desc] }
        - name: hostname
          in: query
          description: Exact match on the storage namespace host.
          schema: { type: string }
        - name: host
          in: query
          description: Match on the request host — substring, or a `*` wildcard.
          schema: { type: string }
        - name: node
          in: query
          description: Edge node that cached the entry. Case-sensitive as stored.
          schema: { type: string }
        - name: path
          in: query
          description: Match on the request path — substring, or a `*` wildcard.
          schema: { type: string }
      responses:
        "200":
          description: A page of cached entries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheKeysPage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CacheRegistryUnavailable" }

  /domains/{domain}/cache/keys/summary:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Cache]
      operationId: getCacheKeysSummary
      summary: Cache totals with per-node breakdown
      description: |
        Entry count and byte size for the domain, broken down by the edge node
        that cached each entry. Accepts the same filters as
        `/domains/{domain}/cache/keys`. Requires `domain.view`.
      parameters:
        - { name: hostname, in: query, schema: { type: string } }
        - { name: host, in: query, schema: { type: string } }
        - { name: node, in: query, schema: { type: string } }
        - { name: path, in: query, schema: { type: string } }
      responses:
        "200":
          description: Totals.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheTotals" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CacheRegistryUnavailable" }

  /domains/{domain}/cache/keys/content:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Cache]
      operationId: getCacheKeyContent
      summary: Download one cached object
      description: |
        The stored bytes behind a listing row, read straight from cache storage —
        your origin is never contacted, so this works even when the site is down.

        Address the entry by its **stored** identity: copy `hostname`, `node` and
        `key_hash` verbatim from a `/domains/{domain}/cache/keys` row. `node` is
        the edge location that cached it, so the same URL cached at three
        locations is three entries and you choose which copy you get.

        The body is returned in its original uncompressed form with the stored
        `Content-Type`, always as an attachment. The cached response's own status
        code and age travel in `X-Nsin-Cache-Status` and `X-Nsin-Cached-At` — the
        HTTP status describes only whether the read succeeded.

        A `404` with `entry is no longer cached` means the listing row outlived
        the object (it expired, was evicted, or was purged). Requires
        `domain.view`.
      parameters:
        - name: hostname
          in: query
          required: true
          description: The row's `hostname` — the storage namespace, not the request host.
          schema: { type: string }
        - name: node
          in: query
          required: true
          description: The row's `node` — the edge location holding this copy. Case-sensitive.
          schema: { type: string }
        - name: key_hash
          in: query
          required: true
          description: The row's `key_hash`.
          schema: { type: string }
      responses:
        "200":
          description: |
            The stored object. `Content-Type` is whatever was cached; the payload
            is the uncompressed body.
          headers:
            X-Nsin-Cache-Node:
              description: Edge location this copy came from.
              schema: { type: string }
            X-Nsin-Cache-Status:
              description: HTTP status of the cached response.
              schema: { type: integer }
            X-Nsin-Cached-At:
              description: When the object was cached (RFC 3339).
              schema: { type: string, format: date-time }
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "400":
          description: Missing or malformed `hostname`, `node` or `key_hash`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Domain or hostname not yours, or the entry is no longer cached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: The stored entry could not be decoded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Cache storage is unavailable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /domains/{domain}/cache/keys/purge:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Cache]
      operationId: purgeCacheKeys
      summary: Purge or refresh selected cached entries
      description: |
        Targets specific entries — either by listing them in `entries`, or by
        matching a `filter`. Supply one or the other.

        * `mode: "delete"` (default) removes the entry and drops it from the
          listing.
        * `mode: "refresh"` only evicts the stored copy, so the next visitor
          re-fills it. The row stays and updates itself.

        `entries` must carry each row's **stored** identity — copy `hostname`,
        `store_path`, `key_hash` and `node` straight from the listing (note the
        request body uses camelCase for these). Entries belonging to another
        domain are rejected.

        `truncated` is `true` when a filter matched more entries than one call
        may touch — repeat the call until it is `false`. Requires `cache.edit`
        and a plan including cache purge.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CachePurgeKeysRequest" }
      responses:
        "200":
          description: Purge result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CachePurgeKeysResult" }
        "400":
          description: Malformed body, or neither `entries` nor `filter` supplied.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Read-only key, insufficient role, or cache purge is not on the plan.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: The domain is disabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CacheRegistryUnavailable" }


  # ---------------------------------------------------------------------------
  # Sharing
  # ---------------------------------------------------------------------------

  /domains/{domain}/members:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Sharing]
      operationId: listDomainMembers
      summary: List domain members
      description: |
        Everyone with access to the domain, including the owner, plus your own
        role and whether you may manage membership.
      responses:
        "200":
          description: Members.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MemberList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/members/{userId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - name: userId
        in: path
        required: true
        description: The member's user id, from the member list.
        schema: { type: integer }
    patch:
      tags: [Sharing]
      operationId: updateDomainMember
      summary: Change a member's role or notifications
      description: |
        Partial update. Changing `role` requires `members.manage`; a member may
        change their own notification preferences without it.

        The owner's role cannot be changed, and the owner always receives every
        notification category.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MemberUpdate" }
      responses:
        "200":
          description: The updated member.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Member" }
        "400":
          description: |
            Invalid user id or body, nothing to update, an invalid role, or an
            attempt to change the owner's role or notifications.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or member not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Sharing]
      operationId: removeDomainMember
      summary: Remove a member
      description: |
        Revokes the member's access. The owner cannot be removed. Requires
        `members.manage`.
      responses:
        "200":
          description: Removed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400":
          description: Invalid user id, or an attempt to remove the owner.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or member not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/invites:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Sharing]
      operationId: listDomainInvites
      summary: List pending invitations
      description: |
        Invitations that have not yet been accepted. Invites addressed to
        someone who already has access are filtered out. Requires
        `members.manage`.
      responses:
        "200":
          description: Pending invitations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  invites:
                    type: array
                    items: { $ref: "#/components/schemas/Invite" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Sharing]
      operationId: createDomainInvite
      summary: Invite someone to the domain
      description: |
        Creates an invitation and emails it.

        An invite is bound to the address it was sent to: forwarding the email
        does not let someone else accept it. Requires `members.manage`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InviteCreate" }
      responses:
        "200":
          description: Invitation created, with its accept link.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invite" }
        "400":
          description: |
            Invalid body, an invalid role, a missing or malformed email, an
            attempt to invite yourself, or an attempt to invite the domain's
            owner.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: That user is already a member.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/invites/{inviteId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/InviteId"
    delete:
      tags: [Sharing]
      operationId: revokeDomainInvite
      summary: Revoke an invitation
      description: Requires `members.manage`.
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or invitation not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/invites/{inviteId}/resend:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/InviteId"
    post:
      tags: [Sharing]
      operationId: resendDomainInvite
      summary: Resend an invitation
      description: |
        Refreshes the invitation's expiry and emails it again. Share links
        cannot be resent. Requires `members.manage`.
      responses:
        "200":
          description: Resent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invite" }
        "400":
          description: The invite is a share link, or is no longer active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or invitation not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /invites/{token}:
    parameters:
      - $ref: "#/components/parameters/InviteToken"
    get:
      tags: [Sharing]
      operationId: getInvite
      summary: Inspect an invitation
      description: |
        What an invitation grants, for the authenticated caller. Use it before
        accepting to show who invited them and to which domain.

        `email_match` reports whether the invitation was addressed to the
        calling account — `POST /invites/{token}/accept` will refuse when it is
        false. When it is false, `invited_email` carries the masked target
        address.

        If the caller already has access, `already_member` is true and the
        remaining fields describe their existing role.
      responses:
        "200":
          description: Invitation details.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InvitePreview" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: The invitation does not exist, has expired, was revoked, or is used up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /invites/{token}/accept:
    parameters:
      - $ref: "#/components/parameters/InviteToken"
    post:
      tags: [Sharing]
      operationId: acceptInvite
      summary: Accept an invitation
      description: |
        Joins the domain with the role the invitation carries.

        The invitation binds to the address it was sent to, so accepting from a
        different account fails with `403` and `code: "invite_email_mismatch"`.
        Accepting when you already have access is a no-op that returns
        `already_member: true`.
      responses:
        "200":
          description: Accepted, or you already had access.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InviteAcceptResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            Read-only key, or the invitation was sent to a different email
            address.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InviteMismatch" }
        "404":
          description: The invitation does not exist, has expired, was revoked, or is used up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Billing (read-only)
  #
  # API keys may read billing state but can never move money. The purchase,
  # switch, auto-renew and wallet top-up endpoints reject every key with 403.
  # All monetary amounts are in Iranian rials.
  # ---------------------------------------------------------------------------

  /wallet:
    get:
      tags: [Billing]
      operationId: getWallet
      summary: Get wallet balance
      description: |
        `negative_since` is set while the balance is below zero. If it stays
        negative past the grace window, paid domains are suspended; it clears as
        soon as the balance is non-negative again.
      responses:
        "200":
          description: Wallet.
          content:
            application/json:
              schema:
                type: object
                properties:
                  wallet: { $ref: "#/components/schemas/Wallet" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /wallet/transactions:
    get:
      tags: [Billing]
      operationId: listWalletTransactions
      summary: List wallet transactions
      description: |
        The wallet ledger, newest first. `amount_rials` is signed: positive is a
        credit, negative a debit. Traffic charges carry the domain they are
        attributed to.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: Ledger rows.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/WalletTransaction" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /wallet/transactions/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Billing]
      operationId: getWalletTransaction
      summary: Get one wallet transaction
      description: |
        The ledger row, plus — for a traffic charge — the per-domain byte and
        cost breakdown of that billing window. A traffic charge is billed once
        per account per window, summed across all your domains, so `by_domain`
        is how you attribute it.
      responses:
        "200":
          description: Transaction detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletTransactionDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such transaction on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /wallet/topup-result:
    get:
      tags: [Billing]
      operationId: getTopupResult
      summary: Look up a top-up payment result
      description: |
        The outcome of a wallet top-up, by payment gateway authority. Reading is
        allowed; starting a top-up is not available to API keys.
      parameters:
        - name: authority
          in: query
          required: true
          description: The payment gateway authority returned when the top-up started.
          schema: { type: string }
      responses:
        "200":
          description: Payment outcome.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  ref_code: { type: string }
                  amount_rials: { type: integer }
        "400":
          description: Missing `authority`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such payment on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /wallet/period-statement:
    get:
      tags: [Billing]
      operationId: getWalletPeriodStatement
      summary: Get the current billing-period statement
      description: |
        A live estimate for the billing period in progress: plan price plus
        traffic accrued so far, per domain.
      parameters:
        - name: subscription_id
          in: query
          description: Which subscription's period to report. Defaults to the current one.
          schema: { type: integer }
      responses:
        "200":
          description: Period statement.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PeriodStatement" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /wallet/period-statements:
    get:
      tags: [Billing]
      operationId: listWalletPeriodStatements
      summary: List completed billing-period statements
      parameters:
        - name: subscription_id
          in: query
          schema: { type: integer }
        - name: limit
          in: query
          schema: { type: integer }
      responses:
        "200":
          description: Completed statements, newest first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/PeriodStatement" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /subscriptions:
    get:
      tags: [Billing]
      operationId: listSubscriptions
      summary: List your subscriptions
      description: Every subscription across your domains.
      responses:
        "200":
          description: Subscriptions.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Subscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /subscriptions/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Billing]
      operationId: getSubscription
      summary: Get one subscription
      responses:
        "200":
          description: Subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such subscription on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /subscriptions/current:
    get:
      tags: [Billing]
      operationId: getCurrentSubscriptionDeprecated
      summary: Current subscription (removed)
      deprecated: true
      description: |
        **Removed.** Subscriptions are per domain. Always returns `410`; use
        `GET /domains/{domain}/subscription` instead.
      responses:
        "410":
          description: Endpoint removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, const: subscription_moved_to_domain }
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /features:
    get:
      tags: [Billing]
      operationId: getAccountFeatures
      summary: Plan summary per domain
      description: One row per domain, with the plan it is on and when that plan expires.
      responses:
        "200":
          description: Per-domain plan summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  domains:
                    type: array
                    items: { $ref: "#/components/schemas/DomainPlanSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /traffic-usage:
    get:
      tags: [Billing]
      operationId: getAccountTrafficUsage
      summary: Traffic usage and pricing across your account
      description: |
        Recent daily traffic rows (up to 90) with totals, plus the current
        per-gigabyte prices.

        Traffic is billed in three tiers — `cached` (served from cache),
        `proxied` (fetched through the edge proxy) and `direct` — each priced
        separately. Older rows may carry only the legacy `bypass_bytes` column;
        those are folded into the direct total.
      responses:
        "200":
          description: Usage rows, totals and prices.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountTrafficUsage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /invoices:
    get:
      tags: [Billing]
      operationId: listInvoices
      summary: List invoices
      responses:
        "200":
          description: Invoices, newest first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /invoices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Billing]
      operationId: getInvoice
      summary: Get one invoice
      description: The invoice with its line items.
      responses:
        "200":
          description: Invoice.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such invoice on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/subscription:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Billing]
      operationId: getDomainSubscription
      summary: Get the domain's current subscription
      description: |
        The active subscription, its period statement, its invoices and the
        wallet movements tied to it. Returns `null` when the domain has no
        subscription. Readable by shared members, not only the owner.
      responses:
        "200":
          description: Current subscription, or `null`.
          content:
            application/json:
              schema:
                oneOf:
                  - type: "null"
                  - type: object
                    properties:
                      subscription: { $ref: "#/components/schemas/Subscription" }
                      period_statement: { $ref: "#/components/schemas/PeriodStatement" }
                      invoices:
                        type: array
                        items: { $ref: "#/components/schemas/Invoice" }
                      transactions:
                        type: array
                        description: |
                          Wallet movements belonging to this subscription — the
                          purchase debit, renewal debits and any refunds of its
                          invoices — newest first. Empty when the term was never
                          paid from the wallet.
                        items: { $ref: "#/components/schemas/WalletTransaction" }
        "400":
          description: Missing domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/subscriptions:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Billing]
      operationId: listDomainSubscriptions
      summary: List the domain's subscription history
      description: Owner only.
      responses:
        "200":
          description: Subscriptions.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Subscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/subscriptions/{id}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Billing]
      operationId: getDomainSubscriptionById
      summary: Get one of the domain's subscriptions
      description: Owner only.
      responses:
        "200":
          description: Subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or subscription not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/features:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Billing]
      operationId: getDomainFeatures
      summary: Get the domain's effective plan entitlements
      description: |
        What this domain's plan actually allows — the resolved values after any
        per-subscription overrides, so this is the authority on whether a
        feature is available.

        A limit of `null` means unlimited. Use this before calling a gated
        endpoint rather than inferring capability from the plan name. Readable
        by shared members.
      responses:
        "200":
          description: Effective entitlements.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainFeatures" }
        "400":
          description: Missing domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/traffic-usage:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Billing]
      operationId: getDomainTrafficUsage
      summary: Get the domain's traffic usage
      description: |
        Daily traffic rows for this domain, with totals and current prices.
        Same three-tier model as the account-wide endpoint. Readable by shared
        members.
      responses:
        "200":
          description: Usage rows, totals and prices.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountTrafficUsage" }
        "400":
          description: Missing domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/invoices:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Billing]
      operationId: listDomainInvoices
      summary: List the domain's invoices
      description: Owner only.
      responses:
        "200":
          description: Invoices, newest first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/invoices/{id}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Billing]
      operationId: getDomainInvoice
      summary: Get one of the domain's invoices
      description: Owner only.
      responses:
        "200":
          description: Invoice.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or invoice not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Support
  # ---------------------------------------------------------------------------

  /tickets:
    get:
      tags: [Support]
      operationId: listTickets
      summary: List your support tickets
      description: Your tickets, most recently updated first.
      responses:
        "200":
          description: Tickets.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/TicketListItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Support]
      operationId: createTicket
      summary: Open a support ticket
      description: |
        Send JSON for a text-only ticket, or `multipart/form-data` to attach
        images.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, message]
              properties:
                subject: { type: string }
                message: { type: string }
          multipart/form-data:
            schema:
              type: object
              required: [subject, message]
              properties:
                subject: { type: string }
                message: { type: string }
                files:
                  type: array
                  description: Image attachments.
                  items: { type: string, format: binary }
      responses:
        "200":
          description: The created ticket.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ticket" }
        "400":
          description: Invalid body, or a missing/oversized subject or message.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tickets/unread-count:
    get:
      tags: [Support]
      operationId: getTicketUnreadCount
      summary: Count tickets with unread replies
      responses:
        "200":
          description: Unread count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tickets/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Support]
      operationId: getTicket
      summary: Get a ticket with its messages
      description: |
        Fetching a ticket marks it read for you, so the unread count drops.
      responses:
        "200":
          description: The ticket, including its message thread.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ticket" }
        "400":
          description: Invalid id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such ticket on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tickets/{id}/messages:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    post:
      tags: [Support]
      operationId: addTicketMessage
      summary: Reply to a ticket
      description: |
        Send JSON for a text-only reply, or `multipart/form-data` to attach
        images.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message]
              properties:
                message: { type: string }
          multipart/form-data:
            schema:
              type: object
              required: [message]
              properties:
                message: { type: string }
                files:
                  type: array
                  items: { type: string, format: binary }
      responses:
        "200":
          description: The created message.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TicketMessage" }
        "400":
          description: Invalid id, invalid body, or an empty/oversized message.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "404":
          description: No such ticket on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Notifications
  # ---------------------------------------------------------------------------

  /notifications:
    get:
      tags: [Notifications]
      operationId: listNotifications
      summary: List your notifications
      description: |
        Every event NSIN raised for you — domain lifecycle, uptime, SSL expiry,
        plan and wallet — newest first. Domain events reach the domain owner and
        any member subscribed to that category, so this returns your own copy.

        `total` and `unread_count` always describe the whole feed, not the
        page or the filter, so they can drive a tab label or a badge directly.
      parameters:
        - name: limit
          in: query
          description: Page size. Default 20, maximum 100.
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: before
          in: query
          description: |
            Return only notifications with a lower id — the cursor for fetching
            the next page. Use the last id of the previous page.
          schema: { type: integer }
        - name: unread
          in: query
          description: Set to `true` to return only notifications you have not seen.
          schema: { type: boolean }
        - name: domain
          in: query
          description: |
            Restrict the feed to one domain, by numeric domain id. Account-wide
            notifications (wallet, invoices, tickets) are excluded when set.
          schema: { type: integer }
      responses:
        "200":
          description: Notifications.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Notification" }
                  total: { type: integer, description: Notifications in the whole feed. }
                  unread_count: { type: integer, description: Unseen notifications in the whole feed. }
                  has_more: { type: boolean, description: Another page exists below this one. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /notifications/unread-count:
    get:
      tags: [Notifications]
      operationId: getNotificationUnreadCount
      summary: Count unseen notifications
      description: The number behind the panel's notification badge.
      responses:
        "200":
          description: Unread count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /notifications/read:
    post:
      tags: [Notifications]
      operationId: markNotificationsRead
      summary: Mark notifications as seen
      description: |
        Stamps the given notifications as seen, which is what removes them from
        the unread count. Send `ids` for specific notifications, or `all: true`
        for the whole feed. Notifications already seen keep their original
        timestamp, so replaying a request is harmless.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Provide `ids` or `all`.
              properties:
                ids:
                  type: array
                  description: Notification ids to mark seen. At most 100.
                  items: { type: integer }
                all:
                  type: boolean
                  description: Mark the whole feed seen. Ignores `ids`.
      responses:
        "200":
          description: The number of notifications updated, and the unread count that remains.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer }
                  unread_count: { type: integer }
        "400":
          description: Neither `ids` nor `all` was given, or more than 100 ids were sent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Account
  # ---------------------------------------------------------------------------

  /proxy-ip/:
    get:
      tags: [Account]
      operationId: getProxyIp
      summary: Get the edge proxy IP
      description: |
        The IP address to point DNS at for an externally-hosted zone. For
        managed domains NSIN sets this automatically when you mark a record
        proxied.
      responses:
        "200":
          description: Proxy IP.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ip: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }


components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer nsin_…`. The token is an NSIN API key, not a JWT.
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        `X-Api-Key: nsin_…`. Equivalent to the bearer form — use whichever suits
        your client.

  parameters:

    DomainName:
      name: domain
      in: path
      required: true
      description: |
        The domain **name** (for example `example.com`) — not a numeric id.
      schema: { type: string }
      example: example.com

    DomainQuery:
      name: domain
      in: query
      required: true
      description: |
        The domain **name** (for example `example.com`). These endpoints take the
        domain as a query parameter rather than a path segment.
      schema: { type: string }
      example: example.com

    Period:
      name: period
      in: query
      description: |
        Time window, ending now. Buckets are hourly up to `24h` and daily for
        `7d` and `30d`. An unrecognised value falls back to `24h`.
      schema:
        type: string
        enum: ["3h", "6h", "12h", "24h", "7d", "30d"]
        default: "24h"

    HostnameFilter:
      name: hostname
      in: query
      description: |
        Narrow to one subdomain. Matches the exact host, any subdomain of it, or
        a bare label — so `example.com` matches `api.example.com`, and `api`
        matches `api.example.com`, but `exam` matches neither.
      schema: { type: string }

    PathFilter:
      name: path
      in: query
      description: Narrow to a URL path prefix.
      schema: { type: string }

    NodeFilter:
      name: node
      in: query
      description: Narrow to one edge node. Use `name` from `GET /analytics/nodes`.
      schema: { type: string }

    RuleId:
      name: ruleId
      in: path
      required: true
      description: Numeric id of the rule.
      schema: { type: integer }

    InviteId:
      name: inviteId
      in: path
      required: true
      description: Numeric id of the invitation.
      schema: { type: integer }

    InviteToken:
      name: token
      in: path
      required: true
      description: The invitation token from the accept link.
      schema: { type: string }

    RecordId:
      name: recordId
      in: path
      required: true
      description: Numeric id of the DNS record.
      schema: { type: integer }

    GatewayId:
      name: gatewayId
      in: path
      required: true
      description: Numeric id of the gateway, from the gateway list.
      schema: { type: integer }

  responses:

    Unauthorized:
      description: |
        Missing, malformed, revoked or expired API key — or the owning account is
        inactive.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            invalidKey:
              value: { error: "invalid API key" }

    Forbidden:
      description: |
        The key is read-only, your role on the domain lacks the required
        permission, or the domain's plan does not include the feature.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    ReadOnlyKey:
      description: |
        The key is read-only and this endpoint is a write. Read-only keys may
        only issue `GET`, `HEAD` and `OPTIONS`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            readOnly:
              value: { error: "read-only API key" }

    DomainNotFound:
      description: |
        No such domain, or it is not visible to this account. Domains you cannot
        access are reported as not found rather than forbidden.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    RuleDomainNotFound:
      description: |
        No such domain, or your role on it does not permit this operation. The
        rules endpoints deliberately answer `404` rather than `403` for an
        insufficient role, so they never confirm that a domain exists to someone
        who cannot use it.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            notFound:
              value: { error: "not found" }

    RuleNotFound:
      description: |
        The domain or the rule does not exist, the rule belongs to another
        domain or another rule type, or your role does not permit this
        operation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    RuleInvalid:
      description: |
        Malformed body, an invalid field value, or `record_ids` containing a
        record that does not belong to this domain.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            foreignRecords:
              value: { error: "record_ids do not belong to this domain" }

    RulePlanLimited:
      description: |
        The key is read-only, or the domain's plan does not include this rule
        type or allows fewer rules of it than you already have.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    DomainQueryRequired:
      description: The `domain` query parameter is missing.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            missing:
              value: { error: "domain is required" }

    AnalyticsPlanLimited:
      description: |
        The domain's plan does not include the feature this endpoint needs
        (`monitoring` for most sections, `logs` for raw and top-N request data).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    AnalyticsUnavailable:
      description: |
        The analytics backend is temporarily unreachable. Retry; no data is
        lost.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            unavailable:
              value: { error: "analytics unavailable" }

    CacheRegistryUnavailable:
      description: The cache registry is temporarily unreachable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    RateLimited:
      description: |
        The key exceeded its request budget (300 requests per minute by default).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            limited:
              value: { error: "rate limit exceeded" }

  schemas:

    Error:
      type: object
      description: The single error shape used by every endpoint.
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable description of what went wrong.
      examples:
        - { error: "read-only API key" }

    Message:
      type: object
      properties:
        message: { type: string }
      examples:
        - { message: "deleted" }

    Role:
      type: string
      description: |
        Your role on a domain. `owner` is implicit for the domain's creator and
        for global admins; the other three are grantable via sharing.
      enum: [owner, admin, editor, viewer]

    Permission:
      type: string
      description: One capability on a domain.
      enum:
        - domain.view
        - domain.settings
        - domain.delete
        - records.edit
        - rules.edit
        - cache.edit
        - ssl.manage
        - analytics.view
        - billing
        - members.manage

    DomainStatus:
      type: string
      description: |
        * `pending` — managed domain waiting for its nameservers to point at NSIN.
        * `unverified` — external-DNS domain waiting for its TXT verification record.
        * `active` — serving.
        * `moved` — delegation has left NSIN; the domain keeps serving during a grace window.
        * `disabled` — not serving; re-enable with `POST /domains/{domain}/enable`.
        * `banned` — administratively blocked.
      enum: [pending, unverified, active, moved, disabled, banned]

    Domain:
      type: object
      description: A domain and its edge configuration.
      properties:
        id: { type: integer }
        name: { type: string, examples: ["example.com"] }
        status: { $ref: "#/components/schemas/DomainStatus" }
        dns_mode:
          type: string
          enum: [managed, external]
          description: |
            `managed` — NSIN hosts the DNS zone. `external` — you host DNS
            elsewhere and prove ownership with a TXT record.
        user_id: { type: integer, description: Id of the owning user. }
        verification_started_at: { type: string, format: date-time }
        cache_l2_max_gb:
          type: integer
          description: Per-domain cap on disk (L2) cache size, in GB.
        cache_l2_ttl_days:
          type: integer
          minimum: 1
          maximum: 7
          description: How long a disk-cache entry may live, in days. Maximum 7.
        cache_cap_mb:
          type: integer
          enum: [128, 256, 512, 2048, 4096]
          description: |
            Largest response body NSIN will buffer and cache, in MB. Bigger
            responses stream straight from origin and are never cached. The
            selectable ceiling depends on the domain's plan.
        developer_mode_until:
          type: string
          format: date-time
          description: |
            While set and in the future, the edge bypasses cache reads and writes
            for this domain. Absent when developer mode is off.
        pending_since: { type: string, format: date-time }
        moved_since: { type: string, format: date-time }
        next_check_at:
          type: string
          format: date-time
          description: When the background nameserver checker will next look at this domain.
        last_manual_ns_check_at:
          type: string
          format: date-time
          description: Last user-triggered nameserver check; these are limited to one per hour.
        sec_no_sniff:
          type: boolean
          description: |
            Send `X-Content-Type-Options: nosniff`. Off by default — it can break
            an origin that mislabels asset MIME types.
        sec_referrer_policy:
          type: boolean
          description: "Send `Referrer-Policy: strict-origin-when-cross-origin`."
        sec_strip_headers:
          type: boolean
          description: Strip origin fingerprint headers from responses.
        markdown_for_agents:
          type: boolean
          description: |
            Serve a Markdown rendering of eligible HTML pages to clients sending
            `Accept: text/markdown`. Requires an active plan.
        outage_alerts:
          type: boolean
          description: Notify the owner when a subdomain suffers a sustained origin outage.
        uptime_threshold_pct:
          type: integer
          minimum: 50
          maximum: 100
          description: Per-minute origin-error percentage that counts as "down".
        uptime_window_min:
          type: integer
          minimum: 2
          maximum: 60
          description: Minutes the domain must stay down before an incident opens.
        uptime_min_requests:
          type: integer
          description: Minimum origin-eligible requests in the window — the traffic floor below which no incident opens.
        uptime_min_active_min:
          type: integer
          description: Minimum populated one-minute buckets required in the window.
        uptime_recover_min:
          type: integer
          description: Consecutive clear minutes before an incident resolves.
        suspended:
          type: boolean
          description: |
            Paused for billing. The edge refuses the domain's TLS handshake, so
            visitors get a connection error. Clears automatically once the
            wallet is no longer negative.
        suspended_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    DomainVerification:
      type: object
      description: The TXT record to publish to prove ownership of an external-DNS domain.
      properties:
        host: { type: string, description: Name to create the TXT record at. }
        type: { type: string, const: TXT }
        value: { type: string, description: Exact TXT value to publish. }
        verified: { type: boolean }
        expires_at: { type: string, format: date-time }
        seconds_remaining: { type: integer }

    DomainDetail:
      allOf:
        - $ref: "#/components/schemas/Domain"
        - type: object
          properties:
            verification: { $ref: "#/components/schemas/DomainVerification" }
            nsin_ns:
              type: array
              description: The canonical (first) accepted nameserver set.
              items: { type: string }
            nsin_ns_sets:
              type: array
              description: |
                Every accepted nameserver set. The delegation must match exactly
                one set in full — sets cannot be mixed.
              items:
                type: array
                items: { type: string }
            current_ns:
              type: array
              description: The nameservers currently observed in the parent zone.
              items: { type: string }
            my_role: { $ref: "#/components/schemas/Role" }
            my_permissions:
              type: array
              items: { $ref: "#/components/schemas/Permission" }
            ns_check_interval_seconds:
              type: integer
              description: How often the background checker re-checks the delegation.

    DomainSslSummary:
      type: object
      properties:
        status: { type: string, examples: ["active", "pending", "failed", "missing"] }
        expires_at: { type: string, format: date-time }
        days_remaining: { type: integer }
        uncovered_hostnames:
          type: array
          items: { type: string }
          description: |
            Proxied names of this domain with no certificate behind them, sorted.
            Absent when everything the domain proxies is covered. `status` still
            describes the certificate the domain does have, so a domain can be
            `valid` here and still list uncovered names.

    DomainWithSsl:
      allOf:
        - $ref: "#/components/schemas/Domain"
        - type: object
          properties:
            ssl: { $ref: "#/components/schemas/DomainSslSummary" }
            subscription:
              type: object
              additionalProperties: true
              description: The domain's active subscription, when it has one.
            verification: { $ref: "#/components/schemas/DomainVerification" }
            my_role: { $ref: "#/components/schemas/Role" }

    DomainCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: The domain to add, without scheme or trailing dot.
          examples: ["example.com"]
        dns_mode:
          type: string
          enum: [managed, external]
          default: managed

    DomainUpdate:
      type: object
      description: |
        Every field is optional; omitted fields are left unchanged.
      properties:
        dns_mode: { type: string, enum: [managed, external] }
        cache_l2_max_gb: { type: integer, minimum: 1 }
        cache_l2_ttl_days: { type: integer, minimum: 1, maximum: 7 }
        cache_cap_mb: { type: integer, enum: [128, 256, 512, 2048, 4096] }
        sec_no_sniff: { type: boolean }
        sec_referrer_policy: { type: boolean }
        sec_strip_headers: { type: boolean }
        markdown_for_agents: { type: boolean }

    DeveloperMode:
      type: object
      properties:
        active: { type: boolean }
        expires_at:
          type: string
          format: date-time
          description: When developer mode switches itself off. Absent when inactive.

    NsCheckResult:
      type: object
      properties:
        ok: { type: boolean, description: Whether the delegation matched an accepted set. }
        status: { $ref: "#/components/schemas/DomainStatus" }
        message: { type: string }
        next_check_at: { type: string, format: date-time }
        nsin_ns:
          type: array
          items: { type: string }
          description: The nameservers the delegation is expected to match.
        current_ns:
          type: array
          items: { type: string }
          description: The nameservers actually observed.

    VerifyResult:
      type: object
      properties:
        verified: { type: boolean, const: true }
        domain: { $ref: "#/components/schemas/DomainDetail" }

    SslCoverageGap:
      type: object
      description: A proxied hostname that the domain's certificate does not cover yet.
      properties:
        hostname: { type: string }
        status: { type: string, enum: [pending, failed, missing] }
        failure_count: { type: integer }
        max_retries: { type: integer }
        next_retry_at: { type: string, format: date-time }
        last_error: { type: string, description: The certificate authority's own reason for the last failure. }

    SslInfo:
      type: object
      properties:
        status: { type: string, examples: ["active", "pending", "failed", "missing"] }
        expires_at: { type: string, format: date-time }
        issued_at: { type: string, format: date-time }
        days_remaining: { type: integer }
        issuer: { type: string }
        subject: { type: string }
        serial_number: { type: string }
        sans:
          type: array
          items: { type: string }
        signature_algorithm: { type: string }
        key_size: { type: integer }
        is_wildcard: { type: boolean }
        auto_renewal: { type: boolean }
        has_private_key: { type: boolean }
        can_manual_issue:
          type: boolean
          description: Whether `POST /domains/{domain}/ssl/issue` would be accepted right now.
        manual_issue_reason:
          type: string
          description: Why manual issuance is unavailable, when `can_manual_issue` is false.
        next_manual_issue_at: { type: string, format: date-time }
        last_issue_attempt_at: { type: string, format: date-time }
        hostname:
          type: string
          description: |
            The name the reported certificate is installed under — usually the
            apex, but a domain may hold a certificate for one subdomain only.
        covered_hostnames:
          type: array
          items: { type: string }
          description: Every name of this domain with an active certificate, sorted.
        uncovered_hostnames:
          type: array
          items: { type: string }
          description: |
            Proxied names with no certificate behind them, sorted. External-DNS
            domains only — for managed domains the same gaps arrive in
            `coverage`, with the retry schedule that applies when we can issue.
        coverage:
          type: array
          description: |
            Proxied hostnames not yet on the certificate. Absent when coverage is
            complete.
          items: { $ref: "#/components/schemas/SslCoverageGap" }

    CustomCertificateUpload:
      type: object
      required: [certificate, private_key]
      properties:
        certificate:
          type: string
          description: |
            PEM-encoded certificate chain. Include intermediates — a leaf-only
            bundle makes clients fail chain verification.
        private_key:
          type: string
          description: PEM-encoded private key matching the certificate.
        hostnames:
          type: array
          items: { type: string }
          description: |
            Which of the certificate's SANs this upload should cover. Use the
            `eligible` list from `POST /domains/{domain}/ssl/parse`.

    CustomCertificateResult:
      type: object
      properties:
        message: { type: string }
        domain: { type: string }
        hostnames:
          type: array
          items: { type: string }
        expires_at: { type: string, format: date-time }
        issued_at: { type: string, format: date-time }
        subject: { type: string }
        sans:
          type: array
          items: { type: string }

    ParsedCertificate:
      type: object
      properties:
        subject: { type: string }
        issuer: { type: string }
        sans:
          type: array
          items: { type: string }
          description: Every SAN on the certificate.
        eligible:
          type: array
          items: { type: string }
          description: The SANs that belong to this domain and may be passed as `hostnames` on upload.
        default_selection:
          type: array
          items: { type: string }
          description: The subset of `eligible` covering the apex and its wildcard.
        expires_at: { type: string, format: date-time }
        issued_at: { type: string, format: date-time }

    RecordType:
      type: string
      enum: [A, AAAA, CNAME, ANAME, NS, TXT, MX, SRV, PTR, CAA, TLSA, SSHFP, URI]

    RecordScheme:
      type: string
      description: |
        Protocol the edge uses to reach the origin for a proxied record.
        `Default` follows the request's own scheme; `Auto` probes.
      enum: [Http, Https, Auto, Default]

    Record:
      type: object
      properties:
        id: { type: integer }
        name:
          type: string
          description: Record name relative to the domain. `@` is the apex.
          examples: ["www", "@"]
        original_name:
          type: string
          description: The fully-qualified name, with trailing dot.
          examples: ["www.example.com."]
        type: { $ref: "#/components/schemas/RecordType" }
        destination:
          type: string
          description: |
            The record's value. For a proxied record this is the **origin** the
            edge connects to, and the published DNS answer is the NSIN proxy IP
            instead — see `dns_content`.
        dns_content:
          type: string
          description: What is actually published in DNS. Equals the proxy IP for proxied records.
        ttl: { type: integer, description: TTL in seconds. }
        proxied:
          type: boolean
          description: |
            Route this hostname through the NSIN edge. Only `A`, `AAAA`, `CNAME`
            and `ANAME` may be proxied.
        captcha: { type: boolean, description: Challenge visitors before passing them to the origin. }
        editable: { type: boolean, description: False for records NSIN manages on your behalf. }
        user_id: { type: integer }
        domain_id: { type: integer }
        scheme: { $ref: "#/components/schemas/RecordScheme" }
        port: { type: integer, description: "Origin port for proxied records. Default: 443." }
        host_header: { type: string, description: Overrides the Host header (and SNI) sent to the origin. }
        monitor: { type: boolean, description: Include this record in uptime monitoring. }
        dest_country:
          type: string
          description: "ISO country code of the destination, detected by NSIN."
        timeout:
          type: integer
          minimum: 1
          maximum: 1800
          default: 15
          description: >
            How long an edge node waits for the origin to start responding
            before returning 504, in seconds. Default 15, maximum 1800 (30
            minutes). Only applies to proxied records.
        mx_priority: { type: integer, minimum: 0, maximum: 65535, description: Only meaningful for `MX`. }
        comment: { type: string, maxLength: 1024, description: Free-form note. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    GatewayTerms:
      type: object
      description: |
        Per-domain acceptance of the gateway terms of use. `accepted` is false
        until someone with records-edit permission accepts them.
      properties:
        accepted: { type: boolean }
        accepted_at: { type: string, format: date-time }
        accepted_by_id: { type: integer }
        accepted_by_name: { type: string }
        accepted_by_email: { type: string }
        version:
          type: integer
          description: Revision of the terms that was accepted.

    OriginRuleTag:
      type: object
      description: |
        An enabled origin rule that overrides where a proxied record's traffic
        goes, meaning the effective origin is not the record's `destination`.
      properties:
        rule_id: { type: integer }
        type: { type: string, enum: [origin_route, origin_pool] }
        name:
          type: string
          description: The rule's optional label. Absent when the rule is unnamed.
        zone_wide:
          type: boolean
          description: True when the rule applies to every proxied record of the domain.
        dry_run: { type: boolean, description: The rule is evaluated but not enforced. }

    RecordWithOriginRules:
      allOf:
        - $ref: "#/components/schemas/Record"
        - type: object
          properties:
            origin_rules:
              type: array
              description: Absent when nothing overrides this record's origin. Routes are listed before pools.
              items: { $ref: "#/components/schemas/OriginRuleTag" }

    GatewayList:
      type: object
      description: |
        The gateway catalog for one domain, plus that domain's gateway quota.

        Gateway traffic is metered separately from the domain's own traffic: a
        plan allows a fixed number of gateway requests per ROLLING 30 days.
        There is no reset date — capacity returns gradually as older requests
        age out of the window. While the allowance is spent, only the gateway
        hostnames stop serving (they answer `429`); the rest of the domain is
        unaffected.
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Gateway" }
        available:
          type: boolean
          description: |
            Whether the domain's plan includes gateways. When false, switching a
            new gateway on is refused; gateways already on stay listed and can
            still be switched off.
        max_requests_30d:
          type: integer
          nullable: true
          description: Requests allowed in the rolling 30-day window. Null means unlimited.
        requests_30d:
          type: integer
          format: int64
          description: Requests served by this domain's gateways in the window, as of `counted_at`.
        quota_exceeded:
          type: boolean
          description: Whether the allowance is currently spent, so the gateways are answering 429.
        counted_at:
          type: string
          format: date-time
          description: When the count was last recomputed. Absent before the first count.
        dns_mode:
          type: string
          enum: [managed, external]
          description: |
            This domain's DNS mode. Gateways need `managed`: on an `external`
            domain the record would live in a zone NSIN does not serve, so
            switching one on is refused whatever the plan says. Gateways already
            on stay listed and can still be switched off.

    Gateway:
      type: object
      description: |
        One entry in the gateway catalog, plus whether it is switched on for the
        domain you asked about. The origin and upstream Host header behind a
        gateway are NSIN's and are not exposed.
      properties:
        id:
          type: integer
          description: Pass this as `gatewayId` to switch the gateway on or off.
        title: { type: string, description: Display name. }
        slug:
          type: string
          description: Prefix of the generated hostname — the record is named `<slug>-<5 digits>`.
        description: { type: string, description: Short explanatory line. May be empty. }
        upstream:
          type: string
          description: |
            The service this gateway forwards to, e.g. `api.openai.com`. Sent as
            the `Host` header upstream. The origin address behind it is not
            exposed.
          example: api.push.apple.com
        icon:
          type: string
          description: Icon as a base64 `data:` URI, ready to use as an `<img>` source. May be empty.
        enabled:
          type: boolean
          description: Whether this gateway is currently on for this domain.
        record:
          allOf:
            - $ref: "#/components/schemas/GatewayRecordRef"
          description: The record created for this gateway. Absent when `enabled` is false.

    GatewayRecordRef:
      type: object
      properties:
        id: { type: integer, description: Record id. }
        name: { type: string, description: "Name relative to the domain, e.g. `chatgpt-84213`." }
        original_name: { type: string, description: "Fully qualified name, e.g. `chatgpt-84213.example.com.`" }

    GatewayStat:
      type: object
      properties:
        gateway_id: { type: integer, description: The gateway this traffic belongs to. }
        hostname: { type: string, description: "Hostname the requests were made to, e.g. `chatgpt-84213.example.com`." }
        requests: { type: integer, description: Total requests over the period. }
        series:
          type: array
          description: One point per bucket, oldest first. The last point is the current, partial bucket.
          items:
            type: object
            properties:
              t: { type: string, format: date-time, description: Bucket start (UTC). }
              c: { type: integer, description: Requests in the bucket. }

    GatewayName:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: |
            Hostname relative to the domain — letters, digits and hyphens.
            Wildcards and `@` are rejected. On `apply` this field is optional;
            leave it out and a name is generated for you.
          example: my-chatgpt

    RecordCreate:
      type: object
      required: [name, type, destination]
      properties:
        name:
          type: string
          description: Name relative to the domain. Use `@` for the apex.
        type: { $ref: "#/components/schemas/RecordType" }
        destination:
          type: string
          description: "IP address, hostname or text content."
        mx_priority: { type: integer, minimum: 0, maximum: 65535 }
        proxied: { type: boolean, default: false }
        captcha: { type: boolean, default: false }
        scheme: { $ref: "#/components/schemas/RecordScheme" }
        port: { type: integer, default: 443 }
        host_header: { type: string }
        monitor: { type: boolean }
        dest_country: { type: string }
        timeout: { type: integer, minimum: 1, maximum: 1800, default: 15, description: "Origin wait before 504, in seconds. Max 1800 (30 minutes)." }
        comment: { type: string, maxLength: 1024 }

    RecordUpdate:
      type: object
      description: Every field is optional; omitted fields keep their current value.
      properties:
        name: { type: string }
        destination: { type: string }
        mx_priority: { type: integer, minimum: 0, maximum: 65535 }
        proxied: { type: boolean }
        captcha: { type: boolean }
        scheme: { $ref: "#/components/schemas/RecordScheme" }
        port: { type: integer }
        host_header: { type: string }
        monitor: { type: boolean }
        timeout: { type: integer, minimum: 1, maximum: 1800, description: "Origin wait before 504, in seconds. Max 1800 (30 minutes)." }
        comment: { type: string, maxLength: 1024 }

    BatchItemResult:
      type: object
      properties:
        id: { type: integer }
        ok: { type: boolean }
        error: { type: string, description: Present only when `ok` is false. }

    BatchResult:
      type: object
      description: |
        Outcome of a best-effort bulk operation. The status code is `200` even
        when some records failed — inspect `results`.
      properties:
        succeeded: { type: integer }
        failed: { type: integer }
        results:
          type: array
          items: { $ref: "#/components/schemas/BatchItemResult" }

    ImportPreviewRecord:
      type: object
      properties:
        name: { type: string }
        type: { $ref: "#/components/schemas/RecordType" }
        destination: { type: string }
        ttl: { type: integer }
        mx_priority: { type: integer }
        proxied: { type: boolean }
        status:
          type: string
          enum: [new, overwrite, unsupported]
          description: |
            `overwrite` means an NSIN record with the same name and type already
            exists and would be replaced.
        existing_id: { type: integer, description: Set when `status` is `overwrite`. }
        reason: { type: string, description: Why an entry is `unsupported`. }

    ImportRecordItem:
      type: object
      required: [name, type, destination]
      properties:
        name: { type: string }
        type: { $ref: "#/components/schemas/RecordType" }
        destination: { type: string }
        ttl: { type: integer }
        mx_priority: { type: integer }
        proxied: { type: boolean }

    ImportResult:
      type: object
      properties:
        created: { type: integer }
        failed:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              type: { type: string }
              error: { type: string }
        records:
          type: array
          description: The domain's full record list after the import.
          items: { $ref: "#/components/schemas/Record" }

    # -------------------------------------------------------------------------
    # Rules — shared pieces
    # -------------------------------------------------------------------------

    RuleType:
      type: string
      enum:
        [cache, drop, redirect, rewrite, waf, captcha, rate_limit, bot_route,
         origin_pool, origin_route, fingerprint, error_page]

    HostMatchType:
      type: string
      description: |
        How the host filter — `host_pattern`, `host_includes` and
        `host_excludes` — is matched. One strategy covers all three, exactly as
        one `path_match_type` covers both path lists.

        The empty string means "no host filter", and is the only valid value
        when the pattern and both lists are empty. Set a list without a match
        type and the API defaults it to `wildcard`.

        A `wildcard` entry matches subdomains, not the label itself:
        `*.example.com` covers `shop.example.com` but not `example.com` — the
        same reading as the DNS wildcard. Add the bare name as its own entry to
        include it.
      enum: ["", exact, wildcard, regex]

    ActionMode:
      type: string
      description: |
        * `enforce` — the rule acts (block, redirect, challenge, …).
        * `dry_run` — the rule matches and is logged as "would have acted", but
          the request reaches the origin unchanged. Use it to test a rule
          safely.

        Not every rule type honours this; cache ignores it.
      enum: [enforce, dry_run]

    RulePathMatchType:
      type: string
      description: How `path_includes` and `path_excludes` are interpreted.
      enum: [wildcard, regex]

    RuleCommon:
      type: object
      description: The fields every rule carries, whatever its type.
      properties:
        id: { type: integer }
        domain_id: { type: integer }
        record_id:
          type: integer
          description: |
            Deprecated single-record scope. Prefer `record_ids`. Absent for
            zone-wide rules.
        record_ids:
          type: array
          items: { type: integer }
          description: |
            The proxied DNS records this rule applies to. Empty or absent means
            zone-wide — every proxied record of the domain.
        type: { $ref: "#/components/schemas/RuleType" }
        enabled: { type: boolean }
        priority:
          type: integer
          description: Evaluation order; lower runs first. Defaults to 100.
        host_pattern:
          type: string
          description: |
            Legacy single-hostname filter, kept for rules written before
            `host_includes` existed. It is evaluated as one more entry of
            `host_includes`; prefer the lists.
        host_match_type: { $ref: "#/components/schemas/HostMatchType" }
        host_includes:
          type: array
          items: { type: string }
          description: |
            Hostnames the rule applies to. Empty or absent means every host the
            scoped record(s) serve — which on a wildcard-proxied zone
            (`*.example.com`) is every subdomain.
        host_excludes:
          type: array
          items: { type: string }
          description: |
            Hostnames carved back out of `host_includes`. An exclude always
            wins over an include, so "everything except staging" is an empty
            include list plus one exclude.
        action_mode: { $ref: "#/components/schemas/ActionMode" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    RuleCommonBody:
      type: object
      description: |
        The shared rule fields accepted by every create and update body. All are
        optional — on create they fall back to defaults, on update an omitted
        field is left unchanged.
      properties:
        record_id:
          type: integer
          nullable: true
          description: Deprecated single-record scope. Prefer `record_ids`.
        record_ids:
          type: array
          items: { type: integer }
          description: |
            Scope the rule to these proxied records. Omit or send an empty array
            for a zone-wide rule. Every id must belong to this domain.
        enabled: { type: boolean, default: true }
        priority: { type: integer, default: 100, minimum: 0 }
        host_pattern: { type: string }
        host_match_type: { $ref: "#/components/schemas/HostMatchType" }
        host_includes:
          type: array
          items: { type: string }
          maxItems: 200
          description: |
            Hostnames the rule applies to; empty or omitted means every host.
            Sending an explicit empty array clears an existing list.
        host_excludes:
          type: array
          items: { type: string }
          maxItems: 200
          description: Hostnames excluded from the rule; excludes beat includes.
        action_mode: { $ref: "#/components/schemas/ActionMode" }

    RulePathScope:
      type: object
      description: Path matching shared by the rule types that filter on the URL path.
      properties:
        path_match_type: { $ref: "#/components/schemas/RulePathMatchType" }
        path_includes:
          type: array
          items: { type: string }
          description: 'Paths the rule applies to. Defaults to `["/*"]` — everything.'
        path_excludes:
          type: array
          items: { type: string }
          description: Paths carved back out of `path_includes`.

    RuleReorderRequest:
      type: array
      description: A bare array — not wrapped in an object.
      items:
        type: object
        required: [id, priority]
        properties:
          id: { type: integer }
          priority: { type: integer, minimum: 0 }

    RuleReorderResult:
      type: object
      properties:
        updated: { type: integer, description: How many rules were changed. }

    # -------------------------------------------------------------------------
    # Rules — cache
    # -------------------------------------------------------------------------

    CacheScope:
      type: string
      description: |
        What the rule caches among the paths it already matches.

        * `default` — static assets only, chosen by file extension.
        * `everything` — every cacheable response, HTML included.

        There is no "custom" scope: narrow what you cache by scoping
        `path_includes` instead.
      enum: [default, everything]

    CacheRuleFields:
      type: object
      properties:
        ttl_sec:
          type: integer
          description: How long an entry stays fresh, in seconds. `0` uses the default.
        refresh_sec:
          type: integer
          description: |
            Background refresh interval in seconds — the entry is re-fetched
            this often while still being served. `0` disables it.
        with_qs:
          type: boolean
          description: Include the query string in the cache key. Off means `?a=1` and `?a=2` share one entry.
        scope: { $ref: "#/components/schemas/CacheScope" }
        bypass_authorization:
          type: boolean
          default: true
          description: |
            Skip caching requests that carry an `Authorization` header. Leave on
            unless you are certain the response is not user-specific.
        bypass_set_cookie:
          type: boolean
          default: true
          description: |
            Skip caching responses that set a cookie. Turning this off can serve
            one visitor's session to another — only do it for responses you know
            are anonymous.
        respect_client_no_store:
          type: boolean
          default: true
          description: "Honour `Cache-Control: no-store` from the client."
        respect_origin_cache_control:
          type: boolean
          default: true
          description: Honour the origin's `Cache-Control` directives.
        respect_origin_max_age:
          type: boolean
          default: true
          description: Use the origin's `max-age` instead of `ttl_sec`.
        bypass_wp_admin:
          type: boolean
          default: true
          description: Never cache WordPress admin and login paths.

    CacheRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/CacheRuleFields"

    CacheRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/CacheRuleFields"

    # -------------------------------------------------------------------------
    # Rules — drop
    # -------------------------------------------------------------------------

    DropRuleFields:
      type: object
      properties:
        country_match_type:
          type: string
          enum: [include, exclude]
          description: |
            Whether `countries` is the set that IS dropped (`include`) or the
            only set that is NOT dropped (`exclude`).
        countries:
          type: array
          items: { type: string }
          description: ISO 3166-1 alpha-2 country codes. Empty means no country filter.
        ip_match_type:
          type: string
          enum: [include, exclude]
          default: include
          description: |
            Whether `ips` is the set that IS dropped (`include`) or the only set
            that is NOT dropped (`exclude` — an allowlist). An `exclude` rule
            must list at least one entry; an empty allowlist would drop every
            request on the matched paths.
        ips:
          type: array
          items: { type: string }
          maxItems: 256
          description: |
            Client addresses the rule is scoped to. Empty means no IP filter.
            Each entry is a single address (`203.0.113.7`, `2001:db8::1`), a
            CIDR prefix (`10.0.0.0/8`), or an inclusive range
            (`10.0.0.1-10.0.0.50`). Entries are stored canonicalized — CIDR
            host bits are masked off and reversed ranges are ordered.

            Under `exclude`, a visitor whose address cannot be determined is
            dropped: it is provably not one of the allowed addresses.

    DropRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/DropRuleFields"

    DropRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/DropRuleFields"

    # -------------------------------------------------------------------------
    # Rules — redirect
    # -------------------------------------------------------------------------

    RedirectRuleFields:
      type: object
      properties:
        target:
          type: string
          description: Where to send the visitor. Absolute URL, or a path when redirecting within the site.
        status_code:
          type: integer
          enum: [301, 302, 307, 308]
          default: 302
          description: |
            The redirect status. `301`/`308` are permanent and cached hard by
            browsers — verify the rule with `302` first.
        preserve_query:
          type: boolean
          default: true
          description: Append the original query string to `target`.
        preserve_path:
          type: boolean
          description: Append the original path to `target`.

    RedirectRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RedirectRuleFields"
        - type: object
          properties:
            www_record:
              type: string
              enum: [covered, created, created_external, unproxied, no_apex, limit, failed]
              description: |
                Only present when *creating* the canonical `www.<domain>` →
                apex (or apex → `www.<domain>`) redirect. Those rules are inert
                without a proxied DNS record for `www` — the edge evaluates
                rules only for hostnames it holds a record for — so the record
                is provisioned alongside the rule and this reports what
                happened: `created` (a proxied www record mirroring the apex
                was added), `created_external` (added, but the zone is hosted
                elsewhere so the owner must still point `www` at us),
                `covered` (one already existed), `unproxied` (a www
                record exists but bypasses the edge, so the redirect will not
                run), `no_apex` (no apex address record to mirror), `limit`
                (the plan is out of record slots), `failed` (the DNS write
                failed). The rule itself is created in every case.

    RedirectRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RedirectRuleFields"

    # -------------------------------------------------------------------------
    # Rules — rewrite
    # -------------------------------------------------------------------------

    RewriteRuleFields:
      type: object
      properties:
        path_target:
          type: string
          description: |
            The path sent to the origin. With `path_match_type: regex` you may
            reference capture groups from `path_includes`.
        query_mode:
          type: string
          enum: [preserve, replace, strip]
          description: |
            * `preserve` — pass the original query string through.
            * `replace` — substitute `query_target`.
            * `strip` — drop the query string entirely.
        query_target:
          type: string
          description: The replacement query string, used when `query_mode` is `replace`.

    RewriteRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RewriteRuleFields"

    RewriteRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RewriteRuleFields"

    # -------------------------------------------------------------------------
    # Rules — web optimization
    # -------------------------------------------------------------------------

    OptimizeRuleFields:
      type: object
      properties:
        images:
          type: boolean
          default: false
          description: |
            Convert JPEG and PNG responses to WebP. Only visitors whose
            `Accept` header advertises WebP are served it — those requests
            occupy a separate cache slot — so a client that cannot decode WebP
            always receives the original file.
        image_quality:
          type: integer
          minimum: 40
          maximum: 100
          default: 80
          description: |
            WebP encoder quality. Lower is smaller and lossier. 80 is the
            recommended balance.
        minify_js:
          type: boolean
          default: false
          description: |
            Strip comments and whitespace from JavaScript. Identifiers are
            never renamed.

            Note: minifying a script breaks any page that loads it with a
            Subresource Integrity hash (`integrity="sha384-..."`), because the
            bytes no longer match, and it invalidates published source maps.
            The edge cannot detect either condition.
        minify_css:
          type: boolean
          default: false
          description: Strip comments and whitespace from CSS.
        compress_level:
          type: integer
          minimum: 0
          maximum: 11
          default: 0
          description: |
            Brotli quality for cached text responses. `0` inherits the node
            default. Lossless, so it carries none of the risk of the other
            actions. Higher levels are applied in the background after the
            first response is served, so they never add latency.

    OptimizeRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/OptimizeRuleFields"

    OptimizeRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/OptimizeRuleFields"

    # -------------------------------------------------------------------------
    # Rules — WAF
    # -------------------------------------------------------------------------

    WafRuleFields:
      type: object
      properties:
        paranoia:
          type: integer
          minimum: 1
          maximum: 4
          default: 1
          description: |
            OWASP CRS paranoia level. Higher catches more attacks and produces
            more false positives — raise it in `dry_run` first.
        threshold:
          type: integer
          minimum: 1
          maximum: 100
          default: 5
          description: Anomaly score at which a request is blocked.
        body_cap_kb:
          type: integer
          minimum: 0
          maximum: 1024
          default: 128
          description: How much request body to inspect, in KB. `0` skips body inspection.
        rule_excludes:
          type: array
          items: { type: string }
          description: CRS rule ids to disable, for tuning out false positives.

    WafRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/WafRuleFields"

    WafRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/WafRuleFields"

    # -------------------------------------------------------------------------
    # Rules — captcha
    # -------------------------------------------------------------------------

    CaptchaRuleFields:
      type: object
      properties:
        ttl_sec:
          type: integer
          description: How long a solved challenge is remembered for that visitor, in seconds.

    CaptchaRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/CaptchaRuleFields"

    CaptchaRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/CaptchaRuleFields"

    # -------------------------------------------------------------------------
    # Rules — basic auth
    # -------------------------------------------------------------------------

    BasicAuthUser:
      type: object
      description: A credential as it is returned — username only.
      properties:
        username: { type: string }
        has_password:
          type: boolean
          description: Always true for a usable credential. Passwords are never returned.

    BasicAuthUserInput:
      type: object
      required: [username]
      properties:
        username:
          type: string
          maxLength: 64
          description: 'Must not contain `:` (RFC 7617 forbids it in the user-id).'
        password:
          type: string
          minLength: 8
          maxLength: 128
          description: |
            Write-only. Omit it for a username that already exists to keep that
            user's current password; required for a new username.

    BasicAuthCommonFields:
      type: object
      properties:
        realm:
          type: string
          maxLength: 128
          default: Restricted
          description: |
            Shown in the browser's sign-in prompt. Must not contain `"`, `\`, or
            control characters.
        bypass_cidrs:
          type: array
          maxItems: 32
          items: { type: string }
          description: |
            IPs or CIDRs whose requests skip the prompt entirely — an office
            network, an uptime monitor. Bare IPs are stored as a full-length
            prefix (`203.0.113.7` → `203.0.113.7/32`).

    BasicAuthRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/BasicAuthCommonFields"
        - type: object
          properties:
            users:
              type: array
              items: { $ref: "#/components/schemas/BasicAuthUser" }

    BasicAuthRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/BasicAuthCommonFields"
        - type: object
          properties:
            users:
              type: array
              minItems: 1
              maxItems: 32
              items: { $ref: "#/components/schemas/BasicAuthUserInput" }
              description: |
                The complete credential list. Sending it replaces what is
                stored, so any username you leave out is removed.

    # -------------------------------------------------------------------------
    # Rules — rate limit
    # -------------------------------------------------------------------------

    RateLimitRuleFields:
      type: object
      properties:
        limit:
          type: integer
          description: Requests allowed per `window_sec` for one key.
        window_sec:
          type: integer
          default: 60
          description: Length of the counting window, in seconds.
        key_by:
          type: string
          enum: [ip, ip_path]
          default: ip
          description: |
            How requests are bucketed. `ip` counts everything from one address
            together; `ip_path` counts each path separately per address.
        on_breach:
          type: string
          enum: [drop, captcha]
          default: drop
          description: What happens to requests above the limit.
        burst:
          type: integer
          description: Extra requests tolerated momentarily above `limit`.

    RateLimitRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RateLimitRuleFields"

    RateLimitRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RateLimitRuleFields"

    # -------------------------------------------------------------------------
    # Rules — bot route
    # -------------------------------------------------------------------------

    BotKind:
      type: string
      description: |
        A bot the edge classifier recognises. `*` matches any of them and is
        accepted in `bot_kinds` even though it is not itself a catalogue entry;
        `generic-bot` is the catch-all user-agent heuristic.
      enum:
        ["*", gptbot, oai-searchbot, chatgpt-user, claudebot, claude-user,
         perplexitybot, perplexity-user, googlebot, google-extended, bingbot,
         ccbot, bytespider, meta-externalagent, amazonbot, applebot,
         duckduckbot, yandexbot, ahrefsbot, semrushbot, mj12bot, generic-bot]

    BotRouteRuleFields:
      type: object
      properties:
        bot_kinds:
          type: array
          items: { $ref: "#/components/schemas/BotKind" }
          description: Which bots this rule matches. Must not be empty.
        require_verified:
          type: boolean
          description: |
            Only match bots whose identity was verified (by reverse DNS or
            published IP ranges), not merely self-declared in the user agent.
        action:
          type: string
          enum: [block, alt_content, alt_origin, tag]
          description: |
            * `block` — refuse the request.
            * `alt_content` — serve `body` with `status` instead of the origin.
            * `alt_origin` — proxy to `alt_dest`:`alt_port` over `alt_scheme`.
            * `tag` — let it through, but tag it in telemetry.
        status:
          type: integer
          default: 200
          description: Status code for `alt_content`.
        body:
          type: string
          description: Response body for `alt_content`.
        alt_dest:
          type: string
          description: Origin address for `alt_origin`.
        alt_port:
          type: integer
          description: Origin port for `alt_origin`.
        alt_scheme:
          type: string
          enum: [http, https]
          description: Scheme used to reach `alt_dest`.

    BotRouteRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/BotRouteRuleFields"

    BotRouteRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/BotRouteRuleFields"

    # -------------------------------------------------------------------------
    # Rules — origin pool
    # -------------------------------------------------------------------------

    OriginEntry:
      type: object
      description: One origin in a pool.
      required: [address]
      properties:
        address: { type: string, description: Origin IP address or hostname. }
        port: { type: integer }
        scheme: { type: string, enum: [http, https] }
        weight:
          type: integer
          description: Relative share of traffic under `round_robin` and `least_load`.
        node_ids:
          type: array
          items: { type: integer }
          description: "Under `lb_type: geo`, the edge nodes that use this origin."
        country:
          type: string
          description: ISO country code of this origin.

    OriginPoolHealthCheck:
      type: object
      properties:
        enabled: { type: boolean }
        path: { type: string, default: "/", description: Probe path. }
        interval_sec: { type: integer, default: 15, description: Seconds between active probes. }
        timeout_sec: { type: integer, default: 5, description: Probe timeout in seconds. }
        unhealthy_threshold:
          type: integer
          default: 3
          description: Consecutive probe failures before an origin is marked down.
        healthy_threshold:
          type: integer
          default: 2
          description: Consecutive probe successes before an origin returns to service.
        eject_sec:
          type: integer
          default: 30
          description: How long a passively ejected origin stays out, in seconds.
        host: { type: string, description: Host header override for the probe. }

    OriginPoolRuleFields:
      type: object
      properties:
        lb_type:
          type: string
          enum: [round_robin, least_load, geo]
          description: |
            How traffic is spread across `origins`. `geo` routes by edge node —
            see `node_ids` on each origin.
        origins:
          type: array
          items: { $ref: "#/components/schemas/OriginEntry" }
        health_check: { $ref: "#/components/schemas/OriginPoolHealthCheck" }
        host_header:
          type: string
          description: Host header (and SNI) sent to the pool's origins.
        name:
          type: string
          maxLength: 64
          description: |
            Optional label for this pool, shown in the panel's rule table and
            next to the records it overrides. Purely for identification — it
            does not affect routing. Omit or send an empty string for none.

    OriginPoolRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/OriginPoolRuleFields"

    OriginPoolRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/OriginPoolRuleFields"

    # -------------------------------------------------------------------------
    # Rules — origin route
    # -------------------------------------------------------------------------

    OriginRouteRuleFields:
      type: object
      properties:
        address: { type: string, description: Origin IP address or hostname for the matched paths. }
        port: { type: integer }
        scheme: { type: string, enum: [http, https] }
        host_header: { type: string, description: Host header (and SNI) sent to this origin. }
        country:
          type: string
          description: "ISO country code of the origin, detected by NSIN."
        name:
          type: string
          maxLength: 64
          description: |
            Optional label for this route, shown in the panel's rule table and
            next to the records it overrides. Purely for identification — it
            does not affect routing. Omit or send an empty string for none.

    OriginRouteRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/OriginRouteRuleFields"

    OriginRouteRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/OriginRouteRuleFields"

    # -------------------------------------------------------------------------
    # Rules — fingerprint
    # -------------------------------------------------------------------------

    FingerprintRuleFields:
      type: object
      properties:
        match_ja4:
          type: array
          items: { type: string }
          description: JA4 TLS fingerprints to match.
        match_ja4h:
          type: array
          items: { type: string }
          description: JA4H HTTP fingerprints to match.
        action:
          type: string
          enum: [drop, captcha, tag]
          default: captcha
          description: What to do with a matching request.

    FingerprintRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/FingerprintRuleFields"

    FingerprintRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/FingerprintRuleFields"

    # -------------------------------------------------------------------------
    # Rules — error page
    # -------------------------------------------------------------------------

    ErrorPageRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - type: object
          properties:
            mode:
              type: string
              enum: [nsin, custom, origin]
              description: |
                * `nsin` — the NSIN branded error page.
                * `custom` — your own HTML, from `content`.
                * `origin` — pass the origin's own response through untouched.
            codes:
              type: array
              items: { type: integer }
              description: The status codes this rule covers.
            content:
              type: object
              additionalProperties: { type: string }
              description: |
                Status code (as a decimal string) → HTML. The key `"0"` is the
                fallback used for any covered code without its own page.
                **Only populated when fetching a single rule** — the list
                endpoint omits it.
            content_codes:
              type: array
              items: { type: integer }
              description: |
                Which codes have HTML, ascending (`0` first when present).
                Always populated, including in the list response.
            content_bytes:
              type: object
              additionalProperties: { type: integer }
              description: Byte size per entry in `content`. Empty on the list endpoint.

    ErrorPageRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - type: object
          properties:
            mode: { type: string, enum: [nsin, custom, origin] }
            codes:
              type: array
              items: { type: integer }
            content:
              type: object
              additionalProperties: { type: string }
              description: |
                Replaces the rule's entire HTML set. Keys are decimal status
                codes; `"0"` is the fallback. Omit the field to leave existing
                content untouched.

    # -------------------------------------------------------------------------
    # Analytics
    # -------------------------------------------------------------------------

    OverviewItem:
      type: object
      properties:
        domain_id: { type: integer }
        domain_name: { type: string }
        total_requests: { type: integer }
        total_bandwidth: { type: integer, description: Bytes. }
        unique_visitors: { type: integer }

    OverviewSeriesPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        count: { type: integer }

    DomainsOverviewItem:
      type: object
      properties:
        domain_id: { type: integer }
        domain_name: { type: string }
        total_requests: { type: integer }
        total_bandwidth: { type: integer, description: Bytes. }
        error_rate:
          type: number
          description: "Share of requests that failed, 0–1."
        latest_event:
          type: string
          format: date-time
          description: Most recent request seen for this domain or any subdomain. Null when there is no traffic.
        series:
          type: array
          description: Requests over time, for a sparkline.
          items: { $ref: "#/components/schemas/OverviewSeriesPoint" }

    GlobalSummary:
      type: object
      properties:
        total_requests: { type: integer }
        total_bandwidth: { type: integer, description: Bytes. }
        unique_visitors: { type: integer }
        error_rate:
          type: number
          description: "Share of requests that failed, 0–1."
        cache_hit_rate:
          type: number
          description: "Share of cacheable requests served from cache, 0–1."
        peak_rps: { type: integer, description: Highest request count in any single second of the period. }
        domains: { type: integer, description: How many domains contributed. }

    AnalyticsSummary:
      type: object
      properties:
        total_requests: { type: integer }
        total_bandwidth: { type: integer, description: Bytes. }
        unique_visitors: { type: integer }
        error_rate:
          type: number
          description: "Share of requests that failed, 0–1."
        avg_response_time: { type: number, description: Mean response time in milliseconds. }
        p90:
          type: number
          description: "90th percentile response time, ms."
        p95:
          type: number
          description: "95th percentile response time, ms."
        p99:
          type: number
          description: "99th percentile response time, ms."

    RequestsDataPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        count: { type: integer }

    BandwidthDataPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        bytes_in: { type: integer }
        bytes_out: { type: integer }

    OriginBandwidthPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        up: { type: integer, description: Bytes sent to the origin. }
        down: { type: integer, description: Bytes received from the origin. }

    DomainBandwidth:
      type: object
      properties:
        domain: { type: string }
        up: { type: integer }
        down: { type: integer }
        ratio: { type: number, description: "`min(up,down) / max(up,down)`." }
        flagged:
          type: boolean
          description: "Set when `ratio` is close to 1.0, which is unusual for web traffic."

    OriginBandwidthResponse:
      type: object
      properties:
        series:
          type: array
          items: { $ref: "#/components/schemas/OriginBandwidthPoint" }
        domains:
          type: array
          items: { $ref: "#/components/schemas/DomainBandwidth" }

    TunnelSuspect:
      type: object
      properties:
        remote_addr: { type: string }
        network: { type: string, description: The client's network operator (AS organisation). }
        country: { type: string }
        hostname: { type: string }
        transport: { type: string, enum: [ws, grpc, xhttp, http] }
        reqs: { type: integer, description: All requests from this client to this host. }
        tunnel_reqs: { type: integer, description: Requests with opaque payloads. }
        tunnel_paths: { type: integer, description: Distinct paths used — around 1 for a tunnel. }
        up: { type: integer, description: Client-to-edge bytes. }
        down: { type: integer, description: Edge-to-client bytes. }
        balance: { type: number, description: "`min/max` of up and down. Informational only." }
        max_secs:
          type: integer
          description: "Longest single connection, in seconds."
        sample_path: { type: string, description: The heaviest single path. }
        ja4: { type: string, description: TLS fingerprint. }
        ja4h: { type: string, description: HTTP fingerprint. }
        ua: { type: string }

    TunnelSuspectsResponse:
      type: object
      properties:
        suspects:
          type: array
          items: { $ref: "#/components/schemas/TunnelSuspect" }

    TopUri:
      type: object
      properties:
        uri: { type: string }
        request_count: { type: integer }

    TopRequestRow:
      type: object
      description: |
        One ranked row. Which fields are populated depends on the `metric` — the
        rest are omitted.
      properties:
        key:
          type: string
          description: "The ranked value — path, country, user agent, hostname or `AS<number>`."
        label:
          type: string
          description: "Network operator name, for the `networks` metric."
        hostname:
          type: string
          description: "Owning host, for path-based metrics."
        asn: { type: integer }
        requests: { type: integer }
        bytes: { type: integer }
        avg_duration: { type: number, description: Milliseconds. }
        max_duration: { type: number, description: Milliseconds. }

    CountryStats:
      type: object
      properties:
        country: { type: string, description: ISO country code. }
        requests: { type: integer }
        bytes: { type: integer }
        unique_visitors: { type: integer }

    AsnStats:
      type: object
      properties:
        asn: { type: integer }
        asn_org: { type: string, description: Network operator name. }
        requests: { type: integer }
        bytes: { type: integer }

    ProtocolStats:
      type: object
      properties:
        protocol: { type: string, description: "`h1`, `h2`, `h3` or `other`." }
        requests: { type: integer }

    TlsSummary:
      type: object
      properties:
        requests: { type: integer, description: TLS-terminated requests in the period. }
        resumed: { type: integer, description: Requests whose TLS session was resumed rather than negotiated afresh. }
        resumption_rate: { type: number, description: "`resumed / requests`, as a percentage; `0` when there were no TLS requests." }

    TlsVersionStats:
      type: object
      properties:
        version: { type: string, description: "Negotiated version, e.g. `TLSv1.3`." }
        requests: { type: integer }
        pct: { type: number, description: Percentage share of all TLS requests. }

    TlsCipherStats:
      type: object
      properties:
        cipher: { type: string, description: "Negotiated cipher suite, e.g. `TLS_AES_128_GCM_SHA256`." }
        requests: { type: integer }
        pct: { type: number, description: Percentage share of the returned suites, which sum to 100%. }

    AiCrawlerSummary:
      type: object
      properties:
        requests: { type: integer }
        allowed: { type: integer, description: Requests answered with a status below `400`. }
        unsuccessful: { type: integer, description: Requests answered with `4xx` or `5xx`. }
        bytes: { type: integer, description: Bytes served to crawlers. }
        markdown_answered: { type: integer, description: Responses the edge rewrote to Markdown. }
        markdown_missed: { type: integer, description: Eligible responses that were not rewritten. }
        markdown_eligible: { type: integer, description: Responses that could plausibly have been Markdown — status below `300`. }

    AiCrawlerStats:
      type: object
      properties:
        kind: { type: string, description: "Bot kind, e.g. `gptbot` or `claudebot`." }
        requests: { type: integer }
        allowed: { type: integer }
        unsuccessful: { type: integer }
        bytes: { type: integer }
        markdown: { type: integer, description: Responses served to this crawler as Markdown. }
        last_seen: { type: string, format: date-time, description: Most recent request from this crawler in the period. }

    AiCrawlerDataPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        requests: { type: integer }
        allowed: { type: integer }
        unsuccessful: { type: integer }
        bytes: { type: integer }
        s2xx: { type: integer }
        s3xx: { type: integer }
        s4xx: { type: integer }
        s5xx: { type: integer }

    AiCrawlerPath:
      type: object
      properties:
        path: { type: string, description: URL path, without the query string. }
        hostname: { type: string }
        requests: { type: integer }

    StatusCodeStats:
      type: object
      properties:
        status_code: { type: integer }
        count: { type: integer }

    UnreachableReason:
      type: object
      properties:
        reason: { type: string, description: 'Canonical key, e.g. `origin_closed`.' }
        label: { type: string, description: Short headline. }
        fault: { type: string, enum: [client, origin, network, config], description: Who is responsible. }
        meaning: { type: string, description: Plain-language explanation. }
        count: { type: integer }
        status: { type: integer, description: Representative HTTP status the visitor saw. }

    UserAgentCategoryStats:
      type: object
      properties:
        category: { type: string }
        requests: { type: integer }

    CacheAnalytics:
      type: object
      properties:
        hits: { type: integer }
        misses: { type: integer }
        bypass: { type: integer }
        hit_rate: { type: number, description: "`hits / (hits + misses)`; `0` when there were no cache lookups." }
        bypass_reasons:
          type: object
          additionalProperties: { type: integer }
          description: Why requests bypassed the cache, by reason.

    TrafficByCacheDataPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        cached_bytes: { type: integer }
        miss_bytes: { type: integer }
        bypass_bytes: { type: integer }

    TrafficByReqStatusDataPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        cache_bytes: { type: integer }
        proxied_bytes: { type: integer }
        direct_bytes: { type: integer }

    TrafficByNodeStats:
      type: object
      properties:
        node: { type: string }
        node_label: { type: string }
        country: { type: string }
        requests: { type: integer }
        bytes_out: { type: integer }
        cached_bytes: { type: integer }
        miss_bytes: { type: integer }
        bypass_bytes: { type: integer }
        cached_requests: { type: integer }
        miss_requests: { type: integer }
        bypass_requests: { type: integer }

    NodeSeriesPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        count: { type: integer }
        bytes: { type: integer, description: Egress bytes in the bucket. }

    NodeDomainStats:
      type: object
      properties:
        domain_id: { type: integer }
        domain_name: { type: string, description: Empty when the traffic matched no registered domain. }
        requests: { type: integer }
        bandwidth: { type: integer, description: Egress bytes. }

    NodeOverviewItem:
      type: object
      properties:
        node:
          type: string
          description: Node identifier. Empty when the serving edge reported no node name.
        node_label: { type: string, description: Human-readable name. Absent for a node that is not registered. }
        country: { type: string, description: ISO country code. }
        registered: { type: boolean, description: Whether the name matches a registered edge node. }
        active: { type: boolean, description: Whether the registered node is in service. }
        requests: { type: integer }
        bandwidth: { type: integer, description: Egress bytes. }
        bytes_in: { type: integer, description: Bytes received from clients. }
        unique_visitors: { type: integer }
        error_rate:
          type: number
          description: "Percentage of requests that returned 4xx or 5xx, 0–100."
        errors_5xx: { type: integer }
        cache_hit_rate:
          type: number
          description: "Percentage of cache lookups served from cache, 0–100."
        cached_requests: { type: integer }
        avg_duration:
          type: number
          description: "Mean response time in milliseconds, WebSocket requests excluded."
        p95_duration:
          type: number
          description: "95th percentile response time in milliseconds, WebSocket requests excluded."
        latest_event:
          type: string
          format: date-time
          description: Most recent request this node served in scope. Null when it served none.
        series:
          type: array
          description: Traffic over time, one bucket per period step.
          items: { $ref: "#/components/schemas/NodeSeriesPoint" }
        top_domains:
          type: array
          description: The busiest domains on this node, at most five.
          items: { $ref: "#/components/schemas/NodeDomainStats" }

    NodesOverviewResponse:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/NodeOverviewItem" }
        totals:
          type: object
          description: What the per-node rows add up to over the same scope.
          properties:
            requests: { type: integer }
            bandwidth: { type: integer, description: Egress bytes. }
            nodes_with_traffic: { type: integer }
            unattributed_requests:
              type: integer
              description: Requests whose row carried no node name.

    OriginNodeStats:
      type: object
      properties:
        node: { type: string }
        node_label: { type: string }
        country: { type: string }
        requests: { type: integer }
        failed: { type: integer }
        errors_5xx: { type: integer }
        avg_upstream:
          type: number
          description: "Mean origin response time, ms."
        bytes_down: { type: integer, description: Bytes received from the origin. }

    OriginStats:
      type: object
      properties:
        origin_addr: { type: string }
        requests: { type: integer }
        failed: { type: integer }
        errors_5xx: { type: integer }
        avg_upstream:
          type: number
          description: "Mean origin response time, ms."
        p95_upstream:
          type: number
          description: "95th percentile origin response time, ms."
        bytes_down: { type: integer }
        nodes:
          type: array
          description: The per-edge-node split behind these totals.
          items: { $ref: "#/components/schemas/OriginNodeStats" }

    LogEntry:
      type: object
      description: |
        One request. Header and body fields are retained for a shorter window
        than the rest of the row, so older entries return them empty.
      properties:
        domain_id: { type: integer }
        timestamp: { type: string, format: date-time }
        hostname: { type: string }
        method: { type: string }
        uri: { type: string, description: Percent-encoded exactly as the client sent it. }
        status: { type: integer, description: Status returned to the visitor. }
        remote_addr: { type: string }
        country: { type: string }
        duration: { type: number, description: Total request duration in ms. For WebSockets this spans the whole connection. }
        bytes_in: { type: integer }
        bytes_out: { type: integer }
        cache_status: { type: string, enum: [hit, miss, bypass] }
        bypass_reason: { type: string }
        req_status: { type: string, enum: [cache, proxied, direct] }
        user_agent: { type: string }
        headers: { type: string, description: Request headers as captured by the edge. }
        origin_req_headers: { type: string, description: Headers the edge sent to the origin. }
        origin_headers: { type: string, description: Headers the origin returned. }
        client_resp_headers: { type: string, description: Headers returned to the visitor. }
        body: { type: string }
        is_ws: { type: boolean }
        content_type: { type: string }
        error: { type: string }
        node: { type: string, description: Edge node that served the request. }
        ray_id: { type: string, description: Unique id for this request. }
        protocol: { type: string, description: 'Client-to-edge protocol, e.g. `HTTP/2.0`.' }
        origin_protocol: { type: string, description: Edge-to-origin protocol. Empty on a cache hit. }
        origin_status: { type: integer, description: Status the origin returned. `0` on a cache hit. }
        origin_error_body:
          type: string
          description: "Bounded prefix of the body the origin sent with a 5xx, which the edge replaced with an error page."
        origin_addr: { type: string, description: 'Origin `IP:port` the edge connected to.' }
        tls_version: { type: string }
        tls_cipher: { type: string }
        tls_resumed: { type: boolean }
        content_encoding: { type: string }
        referer: { type: string }
        cache_age: { type: integer, description: Seconds the served object had been cached. }
        asn: { type: integer }
        asn_org: { type: string }
        bot_kind:
          type: string
          description: "Bot classification, when the request was identified as one."
        bot_verified:
          type: boolean
          description: "Whether the bot's identity was verified, rather than merely claimed."
        detect_action: { type: string, description: Action a detection rule took. }
        detect_dry_run:
          type: boolean
          description: "True when the rule was in dry-run, so nothing was enforced."
        waf_score: { type: integer, description: WAF anomaly score. }
        waf_rule_ids: { type: string, description: CRS rule ids that fired. }
        ja4: { type: string }
        ja4h: { type: string }
        md_converted: { type: boolean, description: The response was served as Markdown. }
        md_tokens: { type: integer }
        orig_tokens: { type: integer }
        md_fail_reason: { type: string }

    LogsResponse:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/LogEntry" }
        total:
          type: integer
          description: "Rows matching the filters, before paging."
        limit: { type: integer }
        offset: { type: integer }

    WafLogEntry:
      type: object
      properties:
        ts: { type: string, format: date-time }
        domainId: { type: integer }
        recordId: { type: integer }
        hostname: { type: string }
        node: { type: string }
        rayId: { type: string }
        clientIp: { type: string }
        country: { type: string }
        clientPort: { type: integer }
        method: { type: string }
        uri: { type: string }
        httpVersion: { type: string }
        action: { type: string }
        blocked: { type: boolean }
        dryRun: { type: boolean, description: True when the rule only logged; the request was not blocked. }
        score: { type: integer, description: Anomaly score reached. }
        paranoia: { type: integer }
        threshold: { type: integer }
        status: { type: integer }
        ja4: { type: string }
        ja4h: { type: string }
        userAgent: { type: string }
        headers: { type: string }
        body: { type: string }
        ruleIds:
          type: array
          items: { type: integer }
        messages:
          type: array
          items: { type: string }
        ruleData:
          type: array
          items: { type: string }
        variables:
          type: array
          items: { type: string }
        severities:
          type: array
          items: { type: integer }
        tags:
          type: array
          items: { type: string }

    MarkdownTesterFetch:
      type: object
      properties:
        status: { type: integer }
        content_type: { type: string }
        content_length:
          type: integer
          description: "Full body length observed, before truncation."
        body: { type: string }
        truncated: { type: boolean }
        binary:
          type: boolean
          description: "The body was not valid UTF-8, so `body` is omitted."
        cache_status: { type: string }
        markdown_tokens: { type: integer }
        original_tokens: { type: integer }
        converted: { type: boolean, description: The response came back as `text/markdown`. }
        error: { type: string }

    MarkdownTesterResult:
      type: object
      properties:
        url: { type: string, description: The URL that was fetched. }
        feature_enabled: { type: boolean, description: The domain's `markdown_for_agents` setting at test time. }
        html: { $ref: "#/components/schemas/MarkdownTesterFetch" }
        markdown: { $ref: "#/components/schemas/MarkdownTesterFetch" }

    AnalyticsQueryResult:
      type: object
      properties:
        columns:
          type: array
          description: Column names, in result order.
          items: { type: string }
        rows:
          type: array
          description: One entry per row, keyed by column name.
          items:
            type: object
            additionalProperties: true
        row_count: { type: integer }
        truncated: { type: boolean, description: True when the 10 000-row cap was reached and results were cut short. }

    # -------------------------------------------------------------------------
    # Uptime
    # -------------------------------------------------------------------------

    OutageIncident:
      type: object
      properties:
        id: { type: integer }
        hostname: { type: string }
        state: { type: string, description: '`open` while ongoing, `resolved` once recovered.' }
        ongoing: { type: boolean }
        started_at: { type: string, format: date-time }
        resolved_at: { type: string, format: date-time, description: Absent while the incident is ongoing. }
        duration_seconds: { type: integer }
        peak_err_pct: { type: number, description: Highest origin-error percentage reached. }
        sample_reqs: { type: integer, description: Requests observed over the incident. }

    UptimeLiveStatus:
      type: object
      properties:
        hostname: { type: string }
        requests: { type: integer }
        errors: { type: integer, description: Origin-attributable 5xx responses. }
        error_pct: { type: number, description: Origin-error percentage across the window. }
        down: { type: boolean, description: Currently meets this domain's alert thresholds. }
        incident: { type: boolean, description: An incident is currently open for this host. }

    UptimeLive:
      type: object
      properties:
        window_min:
          type: integer
          description: "Length of the trailing window, in minutes."
        hosts:
          type: array
          items: { $ref: "#/components/schemas/UptimeLiveStatus" }

    UptimeActive:
      type: object
      properties:
        count: { type: integer, description: Number of outage incidents currently open. }
        hostnames:
          type: array
          description: The subdomains that are down right now.
          items: { type: string }
        since:
          type: string
          format: date-time
          description: When the oldest open incident started. Absent when nothing is down.

    UptimeSettingsBounds:
      type: object
      description: Valid range for each configurable field.
      properties:
        threshold_pct_min: { type: integer }
        threshold_pct_max: { type: integer }
        window_min_min: { type: integer }
        window_min_max: { type: integer }
        min_requests_min: { type: integer }
        recover_min_min: { type: integer }
        recover_min_max: { type: integer }

    UptimeSettings:
      type: object
      properties:
        enabled: { type: boolean, description: Whether outage alerts are sent for this domain. }
        threshold_pct: { type: integer, description: Per-minute origin-error percentage that counts as down. }
        window_min: { type: integer, description: Minutes the host must stay down before an incident opens. }
        min_requests:
          type: integer
          description: "Traffic floor — below this, no incident opens."
        min_active_min: { type: integer, description: Minimum populated one-minute buckets required in the window. }
        recover_min: { type: integer, description: Consecutive clear minutes before an incident resolves. }
        bounds: { $ref: "#/components/schemas/UptimeSettingsBounds" }

    UptimeSettingsUpdate:
      type: object
      description: Every field is optional; omitted fields keep their current value.
      properties:
        enabled: { type: boolean }
        threshold_pct: { type: integer }
        window_min: { type: integer }
        min_requests: { type: integer }
        min_active_min: { type: integer }
        recover_min: { type: integer }

    # -------------------------------------------------------------------------
    # Recommendations
    # -------------------------------------------------------------------------

    RecommendationAction:
      type: object
      description: Where to go to act on the recommendation.
      properties:
        label: { type: string }
        feature: { type: string, description: 'Logical target, e.g. `cache`.' }
        query:
          type: object
          additionalProperties: { type: string }
          description: Parameters that pre-filter the target view.

    Recommendation:
      type: object
      properties:
        key:
          type: string
          description: |
            Stable identifier — pass it to the dismiss endpoints. New checks are
            added over time, so treat this as an open set rather than a closed
            enum.
          enum:
            - cache_off
            - slow_response
            - errors_5xx
            - images_not_webp
            - ssl_expiring
            - no_robots
            - no_sitemap
            - no_https_redirect
            - www_unreachable
            - www_redirect_bypassed
          example: cache_off
        status: { type: string, enum: [ok, warn], description: '`ok` is a passing check; `warn` needs action.' }
        severity: { type: string, enum: [high, medium, low] }
        category: { type: string, enum: [speed, seo, reachability, security] }
        title: { type: string }
        detail: { type: string }
        stats:
          type: object
          additionalProperties: true
          description: Supporting figures behind the finding.
        action: { $ref: "#/components/schemas/RecommendationAction" }
        dismissed:
          type: boolean
          description: "Dismissed by the calling user. Dismissals are per user, not per domain."

    # -------------------------------------------------------------------------
    # Cache
    # -------------------------------------------------------------------------

    PurgeResult:
      type: object
      properties:
        deleted: { type: integer }
        accepted:
          type: boolean
          description: "The purge was queued to run in the background, so `deleted` is not yet known."

    CacheKeyRow:
      type: object
      description: |
        One cached object. `host`/`path`/`query` are the readable request URL;
        `hostname`/`store_path`/`key_hash`/`node` are the stored identity to
        echo back when purging this specific row.
      properties:
        domain_id: { type: integer }
        domain: { type: string }
        host: { type: string, description: 'Exact request host, e.g. `sub.example.com`.' }
        path: { type: string, description: 'Exact request path, e.g. `/assets/app.js`.' }
        query:
          type: string
          description: "Raw query string, without the leading `?`."
        variant:
          type: string
          description: |
            Cache-key suffix separating this entry from other variants of the
            same URL (device, image format, CORS origin, …). Empty for the plain
            variant.
        method: { type: string }
        node: { type: string, description: Edge node that cached it. }
        hostname: { type: string, description: 'Storage namespace host — `*.example.com` for a wildcard record.' }
        store_path: { type: string, description: Raw stored path. Needed for purging; not for display. }
        key_hash: { type: string }
        cache_key: { type: string }
        l2_key:
          type: string
          description: "Reconstructed storage key, for debugging."
        size: { type: integer, description: Bytes. }
        cached_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }

    CacheKeysPage:
      type: object
      properties:
        rows:
          type: array
          items: { $ref: "#/components/schemas/CacheKeyRow" }
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }

    CacheNodeTotal:
      type: object
      properties:
        node: { type: string }
        entries: { type: integer }
        size_bytes: { type: integer }

    CacheTotals:
      type: object
      properties:
        entries: { type: integer }
        size_bytes: { type: integer }
        by_node:
          type: array
          items: { $ref: "#/components/schemas/CacheNodeTotal" }

    CachePurgeTarget:
      type: object
      description: |
        One entry's stored identity. Note the camelCase field names — they differ
        from the snake_case used in the listing response.
      required: [hostname, keyHash, node]
      properties:
        domainId: { type: integer }
        hostname: { type: string, description: The listing's `hostname`. }
        storePath: { type: string, description: The listing's `store_path`. }
        keyHash: { type: string, description: The listing's `key_hash`. }
        node: { type: string, description: The listing's `node`. }

    CachePurgeKeysRequest:
      type: object
      description: Supply either `entries` or `filter`.
      properties:
        mode:
          type: string
          enum: [delete, refresh]
          default: delete
          description: |
            `delete` removes the entry and drops it from the listing;
            `refresh` only evicts the stored copy so the next visitor re-fills it.
        entries:
          type: array
          items: { $ref: "#/components/schemas/CachePurgeTarget" }
        filter:
          type: object
          description: Purge everything matching this filter.
          properties:
            hostname: { type: string }
            node: { type: string }
            path:
              type: string
              description: "Path wildcard, e.g. `/assets/*`."

    CachePurgeKeysResult:
      type: object
      properties:
        deleted: { type: integer }
        mode: { type: string, enum: [delete, refresh] }
        truncated:
          type: boolean
          description: |
            The filter matched more entries than one call may touch. Repeat the
            request until this is false.

    # -------------------------------------------------------------------------
    # Sharing
    # -------------------------------------------------------------------------

    Member:
      type: object
      properties:
        user_id: { type: integer }
        email: { type: string }
        name: { type: string }
        role: { $ref: "#/components/schemas/Role" }
        is_owner: { type: boolean }
        is_self: { type: boolean, description: True for the account this key belongs to. }
        joined_at: { type: string, format: date-time }
        notify_domain: { type: boolean, description: Receive domain status notifications. }
        notify_uptime: { type: boolean, description: Receive outage notifications. }
        notify_ssl: { type: boolean, description: Receive certificate notifications. }

    MemberList:
      type: object
      properties:
        members:
          type: array
          items: { $ref: "#/components/schemas/Member" }
        my_role: { $ref: "#/components/schemas/Role" }
        can_edit: { type: boolean, description: Whether you may manage membership on this domain. }

    GrantableRole:
      type: string
      description: |
        A role that may be assigned to a member or invitation. `owner` is not
        grantable — it always follows domain ownership.
      enum: [admin, editor, viewer]

    MemberUpdate:
      type: object
      description: |
        Partial patch — send only what you want to change. At least one field is
        required.
      properties:
        role: { $ref: "#/components/schemas/GrantableRole" }
        notify_domain: { type: boolean }
        notify_uptime: { type: boolean }
        notify_ssl: { type: boolean }

    Invite:
      type: object
      properties:
        id: { type: integer }
        domain_id: { type: integer }
        email: { type: string, description: The address the invitation is bound to. }
        role: { $ref: "#/components/schemas/GrantableRole" }
        invited_by: { type: integer, description: User id of the inviter. }
        expires_at: { type: string, format: date-time }
        max_uses: { type: integer }
        uses: { type: integer }
        revoked_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        link: { type: string, description: The accept URL. Returned when the invitation is created or resent. }
        is_link: { type: boolean, description: True for a shareable link rather than an emailed invitation. }
        exhausted: { type: boolean, description: True when `uses` has reached `max_uses`. }

    InviteCreate:
      type: object
      required: [email, role]
      properties:
        email: { type: string, format: email }
        role: { $ref: "#/components/schemas/GrantableRole" }
        expires_in_hours: { type: integer, description: Lifetime of the invitation. Omit for the default. }

    InvitePreview:
      type: object
      properties:
        domain: { type: string }
        role: { $ref: "#/components/schemas/Role" }
        inviter: { type: string, description: Display name of whoever sent it. }
        is_link: { type: boolean }
        email_match:
          type: boolean
          description: |
            Whether the invitation was addressed to the calling account.
            Accepting fails when this is false.
        expires_at: { type: string, format: date-time }
        already_member: { type: boolean, description: You already have access; the other fields describe your existing role. }
        is_owner: { type: boolean }
        invited_email: { type: string, description: Masked target address. Present only when `email_match` is false. }

    InviteAcceptResult:
      type: object
      properties:
        accepted: { type: boolean }
        already_member: { type: boolean, description: Returned instead of `accepted` when you already had access. }
        domain: { type: string }
        role: { $ref: "#/components/schemas/Role" }

    InviteMismatch:
      type: object
      properties:
        error: { type: string }
        code: { type: string, const: invite_email_mismatch }
        invited_email: { type: string, description: Masked address the invitation was actually sent to. }

    # -------------------------------------------------------------------------
    # Billing
    # -------------------------------------------------------------------------

    Wallet:
      type: object
      properties:
        id: { type: integer }
        user_id: { type: integer }
        balance_rials: { type: integer }
        negative_since:
          type: string
          format: date-time
          description: |
            When the balance first went below zero in the current debt cycle.
            Absent while non-negative. Staying negative past the grace window
            suspends paid domains.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WalletTransaction:
      type: object
      properties:
        id: { type: integer }
        wallet_id: { type: integer }
        user_id: { type: integer }
        type: { type: string, enum: [topup, purchase, refund, admin_adjust] }
        amount_rials:
          type: integer
          description: "Signed — positive credits, negative debits."
        balance_after: { type: integer }
        description: { type: string }
        ref_type: { type: string, description: 'What the row refers to — `payment`, `subscription`, `traffic` or `manual`.' }
        ref_id: { type: integer }
        ref_code:
          type: string
          description: "Payment gateway reference, for top-ups."
        created_at: { type: string, format: date-time }
        domain_id: { type: integer, description: Set on traffic charges. }
        domain_name: { type: string, description: Set on traffic charges. }

    TrafficDomainBreakdown:
      type: object
      description: One domain's share of a single traffic charge.
      properties:
        domain_id: { type: integer }
        domain_name: { type: string }
        cached_bytes: { type: integer }
        proxied_bytes: { type: integer }
        direct_bytes: { type: integer }
        bypass_bytes:
          type: integer
          description: "Legacy two-tier column, on historical rows only."
        charged_rials: { type: integer }

    WalletTransactionDetail:
      allOf:
        - $ref: "#/components/schemas/WalletTransaction"
        - type: object
          properties:
            node: { type: string }
            billing_window_key: { type: string, description: Identifies the billing window a traffic charge covers. }
            by_domain:
              type: array
              description: Per-domain contribution to this charge. Traffic charges only.
              items: { $ref: "#/components/schemas/TrafficDomainBreakdown" }
            cached_bytes: { type: integer, description: Total across `by_domain`. }
            proxied_bytes: { type: integer, description: Total across `by_domain`. }
            direct_bytes: { type: integer, description: Total across `by_domain`. }
            bypass_bytes: { type: integer, description: Total across `by_domain`. }

    Subscription:
      type: object
      description: |
        A plan attached to one domain. Entitlements are **not** read from the
        plan directly — use `GET /domains/{domain}/features`, which resolves any
        per-subscription overrides.
      properties:
        id: { type: integer }
        user_id: { type: integer }
        domain_id: { type: integer }
        domain_name: { type: string }
        plan_id: { type: integer }
        plan:
          type: object
          additionalProperties: true
          description: The plan this subscription is on.
        plan_term_id: { type: integer }
        plan_term:
          type: object
          additionalProperties: true
          description: The billing term purchased.
        status: { type: string, description: 'For example `active`, `expired`, `grace` or `cancelled`.' }
        started_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        grace_until: { type: string, format: date-time }
        auto_renew: { type: boolean }
        quota_reset_days:
          type: integer
          description: |
            Traffic-allowance reset cadence, frozen at purchase time so later
            plan changes cannot shift an existing subscriber's quota window.
        is_trial:
          type: boolean
          description: |
            The free trial granted at signup. Downgrades to the free plan on
            expiry rather than entering grace.

    DomainPlanSummary:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        plan_slug: { type: string }
        plan_name: { type: string }
        status: { type: string }
        expires_at: { type: string, format: date-time }

    DomainFeatures:
      type: object
      description: |
        The domain's effective entitlements, after per-subscription overrides.
        A `null` limit means unlimited.
      properties:
        plan_id: { type: integer }
        plan_name: { type: string }
        plan_slug: { type: string }
        has_active_plan: { type: boolean }
        max_records: { type: integer, description: Null means unlimited. }
        max_traffic_gb: { type: integer, description: Null means unlimited. }
        max_rules_per_set: { type: integer, description: Rules allowed per rule type. Null means unlimited. }
        max_gateway_requests_30d:
          type: integer
          nullable: true
          description: |
            Requests this domain's gateway records may serve in a rolling 30-day
            window. Null means unlimited. See the gateway list endpoint for
            usage against it.
        max_cache_cap_mb: { type: integer, description: Ceiling for the domain's `cache_cap_mb` — the largest response body cached. }
        max_cache_disk_gb: { type: integer, description: Ceiling for the domain's `cache_l2_max_gb` — the size of its cache pool. }
        max_cache_ttl_days: { type: integer, description: Ceiling for the domain's `cache_l2_ttl_days` — how long a cached entry may live. }
        disabled_rule_types:
          type: array
          items: { type: string }
          description: |
            Rule types this plan may NOT create, by rule `type` string (for
            example `origin_pool`, `origin_route`, `optimize`). A DENY list: any
            type not listed is available. Rules of a blocked type that already
            exist keep working.
        gateways_enabled: { type: boolean, description: Gates the Gateways feature. }
        logs_enabled: { type: boolean, description: Gates the raw-log and top-N analytics endpoints. }
        monitoring_enabled: { type: boolean, description: Gates most analytics sections. }
        rules_enabled: { type: boolean }
        cache_purge_enabled: { type: boolean, description: Gates the cache purge endpoints. }
        custom_ssl_enabled: { type: boolean, description: Gates custom certificate upload. }
        ws_enabled: { type: boolean, description: WebSocket support. }
        host_header_edit_enabled: { type: boolean }
        uptime_sms_enabled:
          type: boolean
          description: |
            Whether uptime outage/recovery alerts may be delivered by SMS. When
            false they still go out by email and to the panel — uptime
            monitoring itself is not gated.
        dedicated_support_enabled: { type: boolean, description: Direct support when true; ticket support when false. }
        domain_usage:
          type: array
          description: Current usage against the limits above.
          items:
            type: object
            additionalProperties: true
        plan_term_id: { type: integer }
        billing_duration_days: { type: integer }
        quota_reset_days: { type: integer }
        quota_period_start: { type: string, format: date-time }
        quota_period_end: { type: string, format: date-time }

    DomainTrafficUsageRow:
      type: object
      description: One day's traffic for one domain.
      properties:
        id: { type: integer }
        domain_id: { type: integer }
        user_id: { type: integer }
        date: { type: string, format: date }
        billing_window_key: { type: string }
        cached_bytes: { type: integer }
        proxied_bytes: { type: integer }
        direct_bytes: { type: integer }
        bypass_bytes:
          type: integer
          description: |
            Legacy two-tier column, present on historical rows. Folded into the
            direct total for display.
        charged_rials: { type: integer }
        window_start: { type: string, format: date-time }
        window_end: { type: string, format: date-time }
        billed_cached_bytes: { type: integer, description: The portion above the plan's free allowance. }
        billed_proxied_bytes: { type: integer, description: The portion above the plan's free allowance. }
        billed_direct_bytes: { type: integer, description: The portion above the plan's free allowance. }
        charged_cached_rials: { type: integer }
        charged_proxied_rials: { type: integer }
        charged_direct_rials: { type: integer }
        invoice_id: { type: integer }
        processed_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    AccountTrafficUsage:
      type: object
      properties:
        usage:
          type: array
          items: { $ref: "#/components/schemas/DomainTrafficUsageRow" }
        total_cached_bytes: { type: integer }
        total_proxied_bytes: { type: integer }
        total_direct_bytes: { type: integer }
        total_charged_rials: { type: integer }
        cached_price_per_gb: { type: integer, description: Rials per GB of cache-served traffic. }
        proxied_price_per_gb: { type: integer, description: Rials per GB of proxied traffic. }
        direct_price_per_gb: { type: integer, description: Rials per GB of direct traffic. }

    InvoiceItem:
      type: object
      properties:
        id: { type: integer }
        invoice_id: { type: integer }
        description: { type: string }
        quantity: { type: integer }
        unit_price_rials: { type: integer }
        total_rials: { type: integer }

    Invoice:
      type: object
      properties:
        id: { type: integer }
        number:
          type: string
          description: "Human-facing invoice number, sequential per Jalali year."
        user_id: { type: integer }
        domain_id: { type: integer }
        domain_name: { type: string }
        subscription_id: { type: integer }
        payment_id: { type: integer }
        kind: { type: string, enum: [subscription, topup, manual] }
        status: { type: string, enum: [paid, unpaid, cancelled] }
        subtotal_rials: { type: integer }
        tax_rials: { type: integer }
        total_rials: { type: integer }
        issued_at: { type: string, format: date-time }
        paid_at: { type: string, format: date-time }
        notes: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        items:
          type: array
          items: { $ref: "#/components/schemas/InvoiceItem" }

    PeriodDomainTraffic:
      type: object
      properties:
        domain: { type: string }
        cached_bytes: { type: integer }
        proxied_bytes: { type: integer }
        direct_bytes: { type: integer }
        estimated_charged_rials: { type: integer }

    PeriodStatement:
      type: object
      description: |
        One billing period's cost. For the period in progress the traffic
        figures are a running estimate.
      properties:
        id: { type: integer }
        subscription_id: { type: integer }
        plan_id: { type: integer }
        plan_name: { type: string }
        plan_term_id: { type: integer }
        billing_duration_days: { type: integer }
        quota_reset_days: { type: integer }
        period_start: { type: string }
        period_end: { type: string }
        plan_price_rials: { type: integer }
        traffic_cached_bytes: { type: integer }
        traffic_proxied_bytes: { type: integer }
        traffic_direct_bytes: { type: integer }
        traffic_bypass_bytes:
          type: integer
          description: "Legacy two-tier column, on historical periods only."
        traffic_charged_rials: { type: integer }
        by_domain:
          type: array
          items: { $ref: "#/components/schemas/PeriodDomainTraffic" }

    # -------------------------------------------------------------------------
    # Support
    # -------------------------------------------------------------------------

    TicketAttachment:
      type: object
      properties:
        id: { type: integer }
        message_id: { type: integer }
        original_name: { type: string }
        content_type: { type: string }
        size_bytes: { type: integer }
        url: { type: string, description: Where to download the attachment. }

    TicketMessage:
      type: object
      properties:
        id: { type: integer }
        ticket_id: { type: integer }
        author_user_id: { type: integer }
        author:
          type: object
          additionalProperties: true
          description: The message author.
        body: { type: string }
        is_staff: { type: boolean, description: True when written by support staff. }
        attachments:
          type: array
          items: { $ref: "#/components/schemas/TicketAttachment" }
        created_at: { type: string, format: date-time }

    Ticket:
      type: object
      properties:
        id: { type: integer }
        user_id: { type: integer }
        subject: { type: string }
        status: { type: string, description: 'For example `open` or `closed`.' }
        closed_at: { type: string, format: date-time }
        closed_by_user_id: { type: integer }
        user_last_read_message_id: { type: integer }
        staff_last_read_message_id: { type: integer }
        messages:
          type: array
          description: The thread. Populated when fetching a single ticket.
          items: { $ref: "#/components/schemas/TicketMessage" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TicketListItem:
      allOf:
        - $ref: "#/components/schemas/Ticket"
        - type: object
          properties:
            is_unread: { type: boolean, description: There are replies you have not read. }

    Notification:
      type: object
      properties:
        id: { type: integer }
        kind:
          type: string
          description: |
            The specific event, for example `domain_active`, `origin_outage`,
            `plan_traffic_high` or `invoice_issued`.
        category:
          type: string
          description: The group the kind belongs to.
          enum: [domain, uptime, ssl, plan, billing, ticket]
        domain_id:
          type: integer
          description: The domain this is about. Absent for account-wide notifications.
        domain_name:
          type: string
          description: Name of `domain_id`, resolved for convenience.
        subject: { type: string, description: Short title, in Persian. }
        message: { type: string, description: The notification body, in Persian. }
        is_unread: { type: boolean, description: You have not marked this seen yet. }
        created_at: { type: string, format: date-time }
