# NOTE ON `nullable: true`
#
# This document declares 3.1, where `nullable` was removed in favour of a type
# union (`type: ["string", "null"]`). The 18 occurrences below are deliberate and
# must NOT be "corrected".
#
# oapi-codegen v2.7.2 does not support 3.1 — it says so on every run — and reads
# this file as 3.0, where `nullable` is the correct spelling. Converting even one
# field to the union form fails the build outright:
#
#   error generating Go schema for property 'company':
#   error resolving primitive type: unhandled Schema type: &[string null]
#
# Since resellerapi/api.gen.go is generated from this file, the 3.0 spelling is
# load-bearing. Both renderers handle `nullable` fine, so nothing is lost by a
# reader. Revisit when oapi-codegen ships 3.1 support.
openapi: 3.1.0

info:
  title: NSIN Reseller API
  version: 1.0.0
  description: |
    The API a reseller uses to manage his own clients on NSIN.

    **This document is the contract.** The Go handlers are generated from it
    (`oapi-codegen`), and the reseller dashboard's TypeScript client is generated
    from it (`openapi-typescript`). Nothing is hand-written on either side. If a
    behaviour is not described here, it does not exist.

    ## Who NSIN is

    NSIN is the **provider**, not a reseller. There is no "reseller #1".
    A direct NSIN customer has `reseller_id = null`.

    ## Money, in one paragraph

    A reseller tops up his own wallet at **face value** through the existing
    Zarinpal flow (not in this API), and everything his clients consume is paid
    for out of that wallet. A plan purchase debits **the client's** wallet at
    NSIN list price, but the money is the reseller's: `POST /services` moves the
    shortfall from his wallet into the client's inside the same transaction —
    both ledger rows carry `ref_type: reseller_autofund` — and only then buys
    the plan. Pre-funding a client with `POST /clients/{clientId}/credit` is
    therefore **optional**; it makes that automatic top-up smaller, or a no-op.
    There is no reseller discount at purchase time, ever.

    **Traffic is not billed to a reseller's client at all.** He has no
    pay-as-you-go: usage beyond the plan's included GB is recorded with
    `charged_rials = 0`, no wallet is debited, and the quota sweep suspends his
    domain instead. `max_traffic_gb` is a cap, not a meter, so a reseller's cost
    for a client is exactly the plans he bought him — nothing arrives later.

    Because the reseller is the one paying, a 402 on a purchase is about **his**
    wallet, not his client's (`credit_limit_reached`). An admin can grant him an
    overdraft — `reseller_max_negative_rials`, default `0`, so by default a
    purchase he cannot afford simply fails — and a reseller who stays below zero
    past the configured grace window has every domain in his tenancy, his own
    and all his clients', suspended until he settles.

    The client's wallet is real, but the client can never see or reach it: every
    wallet, invoice, price and ticket endpoint returns 403 for a user with a
    `reseller_id`.

    ## What the reseller's clients never receive

    All business SMS and email about a reseller's client (domain down, plan
    expiring, balance negative, ...) is redirected to **the reseller**, not the
    client. Two things are deliberately exempt and still go to the client:
    **login OTP** and **password reset** — both are account recovery, and
    suppressing either would lock the client out of the panel permanently.

    ## Amounts

    Every monetary field is an integer count of **Rials** (`*_rials`), matching
    the backend. The UI divides by 10 to display Toman. Never send a float.

    ## Rate limits

    **Every operation in this document is throttled.** One limiter is mounted on
    the whole `/reseller/v1` group, so all 83 routes share the same budget:
    **300 requests per minute** by default, per deployment
    (`RESELLER_RATE_LIMIT`). Over budget is `429` with
    `{"error": "Rate limit exceeded. Slow down and retry shortly.", "code": "rate_limited"}`.

    The budget is counted **per credential, not per reseller**. Each `nsin_live_`
    token has its own, and a dashboard session has another. That is deliberate: a
    runaway WHMCS cron must not be able to lock its owner out of his own panel,
    and revoking that one token must be enough to stop it.

    Two mechanics to build against rather than discover:

    * The window is **fixed, not sliding**. The counter resets a minute after the
      window's first request, so two bursts either side of a reset put 600
      requests through in seconds and are both inside the limit. Do not build a
      client that depends on that.
    * The counter lives **in the API process's memory** and is not shared between
      instances, so a deployment running more than one API process gives a caller
      roughly one budget per process. Treat 300/min as the floor you are
      guaranteed, never as a ceiling you can measure.

    The limiter is mounted **after** authentication and the reseller check, so a
    rejected credential never spends anyone's budget — and a `401` or `403` tells
    you nothing about how much of yours is left.

    Every response the limiter lets through carries `X-RateLimit-Limit`,
    `X-RateLimit-Remaining` and `X-RateLimit-Reset` (seconds until the window
    resets). The `429` itself does **not** carry those; it carries `Retry-After`,
    in seconds. Sleep for that rather than retrying immediately.

    Only the operations most likely to reach the limit — the bulk and list reads
    an integration walks in a loop, and the money writes — declare `429`
    individually. See the `TooManyRequests` response component; it applies to
    every operation here, listed or not.

# One server, deliberately.
#
# A `http://localhost:4000` entry used to sit here for local development. It is
# gone because this document is PUBLISHED: Swagger UI turns every entry into a
# selectable target, browsers remember the selection, and a partner who lands on
# the local one sees every request fail against a host that is not his -- or
# worse, succeeds against something running on his own machine. Development
# overrides the base URL in the client (VITE_API_URL), which never reads this.
servers:
  # api.nsin.cloud, NOT nsin.cloud/api. There is no reverse proxy mapping the
  # /api prefix onto this service: nsin.cloud is the Next.js marketing site,
  # it is built with trailingSlash: true, and so it answers every /api/... call
  # with a 308 to the same path plus a slash and then a prerendered HTML 404.
  #
  # A partner who copies this value out of the reference — or generates a
  # client from this document — POSTs money-moving requests into a static
  # site and gets HTML back where he expects JSON. That is what it did for
  # two days. api.nsin.cloud is the host Coolify actually routes to this
  # container (COOLIFY_FQDN), and TestPublishedServerIsTheAPIHost keeps the
  # two from drifting again.
  - url: https://api.nsin.cloud
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Meta
    description: "One operation, `ping`: an authenticated round trip returning the caller's `reseller_id` and company name, so an integration can prove a credential reaches the right tenancy before it starts provisioning. WHMCS `TestConnection` maps here."
  - name: Clients
    description: "The end-user accounts the caller owns (`users.reseller_id`) — provision one without an OTP, amend it, set its password, and disable or enable its panel login. Their money is under Wallet and their sites under Domains; there is deliberately no delete, and disabling an account does not stop its domains being served."
  - name: Wallet
    description: "Both sides of the money: the reseller's own balance and ledger, any client's balance and ledger, and `credit` — the only call whose entire purpose is moving Rials between two users. It is optional: buying a plan under Services moves the same money by itself, from the reseller's wallet into the client's, inside the purchase transaction. The list prices it all ends up spent at are under Catalog."
  - name: Domains
    description: "The domain object and its lifecycle: add one for a client, read it, list one client's domains or the whole fleet, force a delegation check, change its settings, delete it, and disable/enable it — the switch that actually stops traffic and therefore the meter. What is configured inside a domain lives under Records, Rules, Cache, SSL, Members and Uptime."
  - name: Services
    description: "A service is one `subscriptions` row: buy a plan term for a client's domain against that client's wallet, change it, cancel it, and list across the tenancy what is live and what expires when. Cancelling is a billing act that leaves the site serving — the off switch is `POST /domains/{domainId}/disable` under Domains."
  - name: Catalog
    description: "`GET /plans` alone: the active plans with their active terms and list prices in Rials, which the reseller is shown and his clients never are. Purchases are keyed on `terms[].id`, so this is where a `plan_term_id` comes from."
  - name: Usage
    description: "One bulk aggregate of billable traffic per client and domain over a `from`/`to` date range (`to` is an inclusive day), in bytes cached, proxied and direct plus the Rials already charged. It is the consumption report a daily WHMCS `UsageUpdate` sweep reads in a single call — charts are under Analytics and individual requests under Logs."
  - name: Tokens
    description: "The caller's own `nsin_live_` machine credentials — list, mint once, revoke — not his clients' accounts and not the customer-panel API keys documented in `openapi.yaml`. All three refuse a machine token and require a dashboard session JWT, so a leaked token can neither mint a quieter replacement nor revoke the credential you would use to lock it out."
  - name: Diagnostics
    description: "Two read-only endpoints for working out why an integration is not behaving: `activity` is the reseller's own request log — every call he has made to this API, with the status and error it received — and `diagnostics` is a single round trip that reports which credential arrived, what it resolved to, and the request budget it is spending. They exist because the only evidence a partner previously had was `last_used_at`, a single date, which cannot distinguish a rejected credential from a request that never arrived."
  - name: Support
    description: "Read and write the contact block stored on the reseller's own `users.support_contact`, which his clients are shown in the panel in place of the ticket UI they are 403'd out of. It is an escalation address, not a ticket queue — this API has no ticketing."
  - name: Analytics
    description: "Aggregates: the tenancy-wide traffic roll-up, day-over-day request buckets, and per-domain summary, requests, bandwidth, status-code and cache-ratio series, plus the Markdown-for-Agents preview. The individual rows these counts are computed from are under Logs, and everything here answers 503 rather than a page of zeros when ClickHouse is unreachable."
  - name: Logs
    description: "The raw rows themselves — one per request the edge served and one per WAF decision, with end-user IP, user agent, headers, cache status and matched rule ids — fleet-wide or narrowed to one domain. Paged with `limit`/`offset` rather than `page`/`per_page`, and with no plan-feature gate, unlike the customer API's `/analytics/logs`."
  - name: Records
    description: "DNS records on one of the caller's domains: CRUD, a scan of the domain's live DNS at its current host, zone-file parse and import, and batch update/delete for the bulk changes a per-record loop would rate-limit itself out of. These run the customer panel's own `record` handlers, so the bodies are the customer reference's; a disabled domain is read-only and writes answer 409 `domain_disabled`."
  - name: SSL
    description: "Certificates: the fleet list across every client sorted by soonest expiry, and per domain the current status, custom-certificate upload, PEM inspection before installing, and forced issuance. Private keys are never returned by anything in this document, and issuance and renewal are automatic — `issue` is only the escape hatch for a delegation the customer has just fixed."
  - name: Cache
    description: "What the edge is currently holding for a domain and how to drop it: purge everything, browse the cached entries, read the per-node totals, or purge selected keys. Cache policy — TTLs, bypass conditions — is a rule type under Rules, and how much traffic was served from cache is under Analytics."
  - name: Rules
    description: "The domain's edge rules, all twelve types (`waf`, `cache`, `redirect`, `rewrite`, `rate-limit`, `origin_pool`, ...) behind one route set with the type as `{ruleType}`, rather than the twelve near-identical route sets the customer API mounts. Rules of a type are evaluated in priority order so `reorder` is a behaviour change, and an unrecognised `ruleType` is 404 rather than an empty list."
  - name: Members
    description: "Who besides the owner can reach a client's domain — the member list, role changes, removal, and the invitation flow that creates an NSIN account for someone who has none. The reseller holds this because a managed client is blocked from his own sharing endpoints (`RoleProvider` carries `PermMembersManage`, the client does not), so his agency or developer can only be added here."
  - name: Uptime
    description: "Origin-outage detection for one domain: the history of sustained incidents, the live per-subdomain error picture including hosts that have not crossed the threshold, and the detection thresholds behind both. This is the detector's judgement rather than 5xx counting under Analytics, and its settings write is the one configuration write accepted on a disabled domain."

paths:

  # ---------------------------------------------------------------- Meta

  /reseller/v1/ping:
    get:
      tags: [Meta]
      operationId: ping
      summary: Verify credentials and connectivity
      description: Cheap liveness + auth check. WHMCS `TestConnection` maps here.
      responses:
        '200':
          description: Authenticated
          content:
            application/json:
              schema:
                type: object
                required: [ok, reseller_id, company]
                properties:
                  ok: { type: boolean, const: true }
                  reseller_id: { type: integer, examples: [7] }
                  company: { type: string, nullable: true, examples: ["Parsa Host"] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  # ---------------------------------------------------------------- Clients

  /reseller/v1/clients:
    get:
      tags: [Clients]
      operationId: listClients
      summary: List my clients
      description: |
        Every user whose `users.reseller_id` is the caller, newest first (`id DESC`) — a direct NSIN customer, or another reseller's client, is outside the query that builds the page rather than filtered out of it. `q` is a raw substring match against `email`, `phone_number` and `name`, any one of which may hit; phone numbers are stored normalized (`09123456789`), so searching for `+98912…` finds nothing. Disabled accounts come back like any other — there is no `active` filter — so read `active` per row.

        Each row carries `wallet_balance_rials`, the number the client himself is never shown. A balance that fails to load is rendered as `0` and logged, so a zero here is not proof of an empty wallet. `per_page` above 100 is clamped rather than rejected: ask for 1000 and you silently get 100, so drive your loop off `meta.total`.
      parameters:
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PerPage' }
        - name: q
          in: query
          description: Free-text match on name, email, or phone.
          schema: { type: string }
      responses:
        '200':
          description: A page of clients
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Client' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Clients]
      operationId: createClient
      summary: Create a client
      description: |
        Creates an end user owned by the calling reseller (`users.reseller_id = <caller>`).

        **No OTP is required or consumed.** A reseller provisions a client from
        his own system, where there is nobody to type a code. The client is
        created with `phone_verified = true` and
        `phone_verified_by = "provider"`: you entered the number and you vouch
        for it, so your customer is never challenged to re-prove it.

        He is upgraded to `phone_verified_by = "otp"` automatically the first
        time he happens to sign in with a one-time code — that is stronger
        evidence, and it is recorded as such.

        Note that a client detached from your account by an NSIN administrator
        loses a `provider` verification and must prove the number himself, since
        the vouching that backed it no longer applies.

        This does not weaken the public `POST /auth/register`, which still
        requires an OTP. The shared creation logic is factored out; the OTP gate
        stays on the public route only.

        Phone and national code are validated with the same validators the panel
        uses (`ValidIranianPhone`, `ValidIranianNationalCode`) — a reseller must
        supply real identity data, not placeholders.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateClientRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Client' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: Email, phone, or national code already registered.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/clients/{clientId}:
    parameters:
      - { $ref: '#/components/parameters/ClientId' }
    get:
      tags: [Clients]
      operationId: getClient
      summary: Get one client
      description: |
        The same object one row of `GET /clients` carries, `wallet_balance_rials` included. `clientId` is the numeric `users.id` — an email or a name is not accepted.

        A client belonging to another reseller, a client id that never existed, and an id that is not a number all answer **404** with the same body. That is deliberate: a 403 that read differently from a 404 would let you walk the id space and count a competitor's customers, so outside your tenancy nothing exists. The 403 below means only that the credential itself is not a reseller's.
      responses:
        '200':
          description: The client
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Client' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Clients]
      operationId: updateClient
      summary: Update a client
      description: Partial update. Omitted fields are left unchanged.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateClientRequest' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Client' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
    # There is deliberately NO delete operation on a client.
    #
    # A reseller cannot remove a customer account, and this is a hard product
    # rule, not a limitation waiting to be lifted. A client account is the anchor
    # for domains, subscriptions, invoices and wallet history — records that
    # outlive the commercial relationship and that neither party may quietly
    # erase. Deleting one either destroys that history or orphans it; there is no
    # third outcome.
    #
    # `POST /clients/{clientId}/disable` is what ending a relationship looks like:
    # the client can no longer sign in, the record stays intact, and it is
    # reversible if it turns out to be a mistake.
    #
    # DELETE on this path returns 405.

  /reseller/v1/clients/{clientId}/disable:
    parameters:
      - { $ref: '#/components/parameters/ClientId' }
    post:
      tags: [Clients]
      operationId: disableClient
      summary: Disable a client's account
      description: |
        Sets `users.active = false`. The client can no longer log into the panel.

        This is **account** suspension, and is distinct from **domain**
        disabling (`POST /domains/{domainId}/disable`), which is what stops a
        site from being served. Disabling the account alone does not stop
        traffic, and therefore does not stop billing. To stop the meter, disable
        the domains.
      responses:
        '200':
          description: Disabled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Client' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/clients/{clientId}/enable:
    parameters:
      - { $ref: '#/components/parameters/ClientId' }
    post:
      tags: [Clients]
      operationId: enableClient
      summary: Restore a client's account
      description: |
        Sets `users.active = true`, and that is the whole effect. The client can sign in again — login and OTP request stop refusing him, and the routes that answer `account_suspended` let him through — but the sessions revoked when you disabled him are **not** restored; he logs in fresh.

        It does not touch his domains. Anything you stopped with `POST /domains/{domainId}/disable`, or the system stopped for quota or balance, is still stopped and needs its own enable. Enabling a client who is already enabled rewrites the same value and returns the same `Client`, so a retry is safe.
      responses:
        '200':
          description: Restored
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Client' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/clients/{clientId}/password:
    parameters:
      - { $ref: '#/components/parameters/ClientId' }
    post:
      tags: [Clients]
      operationId: setClientPassword
      summary: Set a client's password
      description: |
        Writes a new bcrypt hash and then revokes **every session the client holds**. The auth middleware checks the session row on each request, so whoever was signed in as him is thrown out on his next call — which is the point: a password is reset to take an account back, and leaving the old sessions alive would achieve nothing.

        **Nobody tells the client.** No SMS, no email and no panel notice is sent, so the new password exists only where you put it and delivering it is your job. Minimum 8 characters, counted in runes — stricter on purpose than account creation, which accepts any non-empty password, because you are choosing this one on somebody else's behalf.

        One trap: if the hash is written but the session revocation then fails, the call still answers `204` and only logs the failure. The password really did change; the old sessions may have survived it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string, minLength: 8, format: password }
      responses:
        '204': { description: Password set }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  # ---------------------------------------------------------------- Wallet

  /reseller/v1/clients/{clientId}/credit:
    parameters:
      - { $ref: '#/components/parameters/ClientId' }
    post:
      tags: [Wallet]
      operationId: creditClient
      summary: Transfer credit from my wallet to a client's wallet
      description: |
        Moves Rials from the calling reseller's wallet into the client's wallet.

        **This call is optional.** `POST /services` performs the same transfer
        itself, for exactly the shortfall the purchase needs, inside the purchase
        transaction — a client never has to be pre-funded to be provisioned.
        Pre-crediting him only makes that automatic top-up smaller, or a no-op.
        Use it if you want the balance sitting there in advance; skip it and
        provisioning still works.

        Atomic: one debit and one credit, in a single transaction, or neither.
        Both wallet rows are locked `FOR UPDATE` in ascending id order to avoid
        deadlock under concurrent transfers.

        **This call cannot overdraw you.** It debits your wallet only if the full
        amount is already there, and answers 402 otherwise. The overdraft an
        admin may have granted you (`reseller_max_negative_rials`, default `0`)
        is reachable through a PURCHASE, not through this transfer.

        So a reseller's wallet CAN go negative, and there IS a reseller-side
        suspension policy: stay below zero past the configured grace window
        (`reseller_negative_grace_days`) and every domain in your tenancy — your
        own and all your clients' — is suspended. Bringing the balance back to
        zero or above resumes the domains that ladder suspended; a domain you
        disabled by hand stays disabled.

        A rejected transfer writes **no** ledger row. Not a reversed one. None.

        The pair of rows this writes carries `ref_type: reseller_transfer`; the
        automatic top-up under `POST /services` writes `reseller_autofund`. That
        is how you tell the two apart when reconciling a ledger.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_rials]
              properties:
                amount_rials:
                  type: integer
                  format: int64
                  minimum: 1
                  description: Must be positive. To claw credit back, see `debit`.
                  examples: [5000000]
                description:
                  type: string
                  maxLength: 191
                  description: Shown on both ledger rows.
      responses:
        '200':
          description: |
            Transferred — or, if this `Idempotency-Key` was already used for this
            exact request, the first call's response replayed unchanged.
          headers:
            Idempotent-Replay:
              description: |
                Sent as `true` only on a replay: the body is the stored answer
                from the first call with this `Idempotency-Key`, and the
                operation did NOT run a second time. Absent on the call that
                actually did the work — which is how a retrying client tells
                which of its attempts moved the money.
              schema: { type: string, enum: ['true'] }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TransferResult' }
        '400':
          description: |
            A malformed body, or a value the handler rejects outright.

            Also the answer to a **missing `Idempotency-Key`**: the generated
            wrapper rejects that before the handler is entered, so the body
            carries the wrapper's message rather than the handler's. A key longer
            than 191 bytes is rejected by the handler, with a message saying so.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: |
            Insufficient funds in the reseller's wallet: this transfer is checked
            against the balance you actually hold, with no overdraft. No ledger
            row was written, and no idempotency key was recorded — retry with the
            same `Idempotency-Key` once you have topped up.

            The body has no `code`. A 402 from `POST /services` is a different
            failure and carries `"code": "credit_limit_reached"`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/clients/{clientId}/debit:
    parameters:
      - { $ref: '#/components/parameters/ClientId' }
    post:
      tags: [Wallet]
      operationId: debitClient
      summary: Withdraw credit from a client's wallet back into mine
      description: |
        The reverse of `credit`: moves Rials out of the client's wallet and back
        into the calling reseller's. Use it to reclaim funds you advanced — a
        client who cancels, a top-up sent to the wrong account, a trial you are
        winding down.

        Mechanically it is the same transfer as `credit` with the two parties
        swapped, so it inherits the same guarantees: one debit and one credit in a
        single transaction or neither, both wallet rows locked `FOR UPDATE` in
        ascending id order so concurrent transfers in opposite directions cannot
        deadlock, and **no ledger row at all** when it is refused.

        **It cannot take a client's balance below zero.** There is no overdraft on
        this path in either direction. Asking for more than the client holds is
        `402` and nothing moves — so a client whose wallet is already negative
        from historical traffic billing cannot be drained further.

        **It does not check where the money came from.** If a client funded his
        own wallet, this will take it. That is a deliberate limitation, not an
        oversight: NSIN does not track which Rial came from whom, and refusing on
        that basis would need a provenance ledger that does not exist. Treat the
        endpoint as the privileged operation it is.

        **It does not cancel anything.** Withdrawing the money behind an active
        subscription does not end that subscription — the plan is already paid
        for and runs to its expiry. Cancel it with `DELETE /services/{serviceId}`
        if that is what you mean.

        Both ledger rows carry `ref_type: reseller_transfer`, the same as `credit`.
        The direction is the sign on `amount_rials`.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_rials]
              properties:
                amount_rials:
                  type: integer
                  format: int64
                  minimum: 1
                  description: |
                    Must be positive — the direction is the endpoint, not the sign.
                    A negative amount is rejected rather than quietly treated as a
                    credit, because a sign flip in a caller's code must not be able
                    to turn a withdrawal into a payment.
                  examples: [2000000]
                description:
                  type: string
                  maxLength: 191
                  description: Shown on both ledger rows.
      responses:
        '200':
          description: |
            Withdrawn — or, if this `Idempotency-Key` was already used for this
            exact request, the first call's response replayed unchanged.
          headers:
            Idempotent-Replay:
              description: |
                Sent as `true` only on a replay: the body is the stored answer from
                the first call with this `Idempotency-Key`, and the operation did
                NOT run a second time.
              schema: { type: string, enum: ['true'] }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TransferResult' }
        '400':
          description: A malformed body, a non-positive amount, or a missing `Idempotency-Key`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: |
            The client does not hold that much. Nothing moved, no ledger row was
            written, and no idempotency key was recorded — retry with the same key
            for a smaller amount, or read `GET /clients/{clientId}/wallet` for the
            balance first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/clients/{clientId}/wallet:
    parameters:
      - { $ref: '#/components/parameters/ClientId' }
    get:
      tags: [Wallet]
      operationId: getClientWallet
      summary: A client's wallet balance and ledger
      description: |
        Visible to the **reseller only**. The client themselves gets 403 on every
        wallet endpoint — they are never shown a balance, a price, or an invoice.
      parameters:
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PerPage' }
      responses:
        '200':
          description: Balance and a page of transactions
          content:
            application/json:
              schema:
                type: object
                required: [balance_rials, negative_since, transactions, meta]
                properties:
                  balance_rials:
                    type: integer
                    format: int64
                    description: |
                      What has been transferred in, less what his plans cost.

                      **Traffic cannot push it below zero.** A reseller's client
                      has no pay-as-you-go: his overage is recorded with
                      `charged_rials = 0`, no wallet is debited, and the quota
                      sweep suspends his domain at the plan's limit instead.

                      A negative number here is therefore not a traffic bill and
                      not a normal state — nothing in the reseller flow debits a
                      client wallet it has not just funded. Read `negative_since`
                      for what happens next.
                    examples: [4500000]
                  negative_since:
                    type: string
                    format: date-time
                    nullable: true
                    description: |
                      When the balance first went below zero. A reseller's client
                      gets **no grace at all** — he has no wallet he can reach and
                      no way to settle — so the next negative-balance pass stops
                      every domain he owns, and **the reseller gets the SMS**, not
                      the client. Credit him back to zero (or let a purchase
                      auto-fund him) and the same ladder resumes what it stopped.
                  transactions:
                    type: array
                    items: { $ref: '#/components/schemas/WalletTransaction' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/wallet:
    get:
      tags: [Wallet]
      operationId: getMyWallet
      summary: My own wallet balance
      description: |
        The reseller's own wallet. Topped up at **face value** through the
        existing Zarinpal flow in the panel — there is no OFF, no bonus, and no
        credit limit in v1. Topping up is not part of this API.
      responses:
        '200':
          description: Balance
          content:
            application/json:
              schema:
                type: object
                required: [balance_rials]
                properties:
                  balance_rials: { type: integer, format: int64, examples: [48500000] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/wallet/transactions:
    get:
      tags: [Wallet]
      operationId: listMyWalletTransactions
      summary: My own wallet ledger
      description: |
        The **caller's own** ledger (`wallet_transactions WHERE user_id = <caller>`) — never a client's. For a client's balance and ledger use `GET /clients/{clientId}/wallet`, which returns both in one call.

        What lands here is `topup` rows from the reseller's own Zarinpal top-ups, and one `transfer_out` row per successful `POST /clients/{clientId}/credit` whose `amount_rials` is **negative**; the matching `transfer_in` row is written to the client's ledger, not this one. Newest first (`id DESC`), `per_page` defaults to 25 and is clamped to 100, and there is no date filter — page back through it and count against `meta.total`.
      parameters:
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PerPage' }
      responses:
        '200':
          description: A page of transactions
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WalletTransaction' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  # ---------------------------------------------------------------- Domains

  /reseller/v1/clients/{clientId}/domains:
    parameters:
      - { $ref: '#/components/parameters/ClientId' }
    get:
      tags: [Domains]
      operationId: listClientDomains
      summary: List a client's domains
      description: |
        Every domain the client owns, newest first, as a bare array — no pagination, no envelope, and nothing joined on, so a client with four hundred domains returns four hundred rows and no subscription or traffic figures.

        The scope is the owner: `WHERE user_id = <clientId>`, on a client `tenantGuard` has already proved is yours. Note the asymmetry with `GET /domains/{domainId}`, which additionally requires the denormalized `domains.reseller_id` to agree with the owner's `users.reseller_id` — a domain whose two columns have drifted apart still appears in this list and answers **404** when fetched directly.

        Soft-deleted domains are excluded, and a client with no domains gets `[]`, never `null`.
      responses:
        '200':
          description: The client's domains
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Domain' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Domains]
      operationId: addClientDomain
      summary: Add a domain for a client
      description: |
        **Only the reseller can do this.** A client with a `reseller_id` gets 403
        from `POST /domains/` in the panel, and the "add domain" button is hidden.

        The reason is money: domains generate billable traffic against a wallet
        the *reseller* funded. Letting the client add domains freely would let
        him spend his reseller's money on decisions the reseller never approved.

        Sets `domains.reseller_id = <caller>` to match the owner's
        `users.reseller_id`. **These two must never disagree.**

        ### The domain arrives with NO plan

        A domain added through the panel gets a free 10-day Enterprise trial. One
        added here does **not**, and you should be glad: the trial is an *active
        subscription*, so it would make your very next call — `POST /services` —
        fail with `409 already_has_active_plan`. Provisioning would break on step
        two, every time.

        So the normal flow is two calls, in this order:

        1. `POST /clients/{clientId}/domains`
        2. `POST /services` — buy the plan

        Until step 2 the domain has a **zero traffic allowance**, and because a
        reseller's client has no pay-as-you-go, the quota sweep will stop it as
        soon as it serves a byte. Buy the plan.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, examples: ["example.com"] }
                dns_mode:
                  type: string
                  enum: [managed, external]
                  default: managed
      responses:
        '201':
          description: Created. The domain starts in `pending` until NS delegation is confirmed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Domain' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: Domain already exists.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}:
    parameters:
      - { $ref: '#/components/parameters/DomainId' }
    get:
      tags: [Domains]
      operationId: getDomain
      summary: Get one domain
      description: |
        The domain row as stored, handed straight from what `tenantGuard` already resolved — no second query, and therefore no plan, subscription or traffic data; those are `GET /services?client_id=` and the analytics endpoints. Remember that `suspended`, not `status`, is what decides whether the site is being served.

        `{domainId}` is **numeric on this whole surface**. The guard parses it as an integer before any handler runs, so `GET /reseller/v1/domains/example.com` is a 404 rather than a lookup — even though the delegated per-domain routes below are the customer panel's own handlers, which address a domain by name and would happily resolve one.

        A domain that is not in your tenancy answers **404**, deliberately indistinguishable from one that does not exist; the 403 is for a caller who is not a reseller at all.
      responses:
        '200':
          description: The domain
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Domain' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    put:
      tags: [Domains]
      operationId: updateDomainSettings
      summary: Change a domain's settings
      description: |
        DNS mode, developer mode, cache cap, proxy defaults — the domain's own
        configuration, as opposed to its subscription.

        Served by the same handler the customer panel calls, so a setting means
        the same thing on both surfaces. Body shape: see the customer reference.

        Not the off switch. Use `POST /domains/{domainId}/disable` for that.
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Domains]
      operationId: deleteDomain
      summary: Delete a domain
      description: Deletes the domain and its records. Any active service on it is cancelled.
      responses:
        '204': { description: Deleted }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/verify:
    parameters:
      - { $ref: '#/components/parameters/DomainId' }
    post:
      tags: [Domains]
      operationId: verifyDomain
      summary: Force an NS delegation check
      description: |
        Normally the checker confirms delegation on its own schedule. This forces
        an immediate check, for a reseller who has just pointed the nameservers
        and does not want to wait.

        Only applies to a **managed-DNS** domain in the `pending` or `moved`
        state. Anything else — an already-active domain, or one on external DNS —
        has nothing to check and returns **400**.
      responses:
        '200':
          description: Check performed. Read `status` for the result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Domain' }
        '400':
          description: |
            The domain is not awaiting nameserver changes, so there is nothing to
            check.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/disable:
    parameters:
      - { $ref: '#/components/parameters/DomainId' }
    post:
      tags: [Domains]
      operationId: disableDomain
      summary: Disable a domain (stop serving it)
      description: |
        Stops the domain being served and pushes a reload to the edge, so it
        takes effect within seconds. **This is what actually stops traffic — and
        therefore stops the meter.**

        DNS records are left completely untouched, so enabling restores service
        immediately with no reconfiguration. The domain is left read-only while
        disabled: records, rules and cache settings cannot be changed, because
        editing a site that is not being served only looks like it worked.

        Disable/enable is the **only** on/off vocabulary a reseller has. The
        underlying column is shared with NSIN's own billing stops (an unpaid
        wallet, an exhausted traffic allowance) — those are set by the system,
        not by you, and `suspended_reason` on the Domain object tells them apart.

        WHMCS `SuspendAccount` maps here.
      responses:
        '200':
          description: Disabled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Domain' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/enable:
    parameters:
      - { $ref: '#/components/parameters/DomainId' }
    post:
      tags: [Domains]
      operationId: enableDomain
      summary: Enable a domain (resume serving it)
      description: |
        Lifts a stop **you** made with `POST /domains/{domainId}/disable`.

        > ### It will not lift a stop the system made.
        >
        > If the domain was stopped because the client **exhausted his plan's
        > traffic** (`quota_exceeded`), this returns **409**. Your client is
        > never billed for overage, so resuming him here would simply hand him
        > unlimited free traffic.
        >
        > The fix is to **buy him a bigger plan**. The quota sweep then resumes
        > the domain by itself, with no further call from you.
        >
        > A stop for `negative_balance` is nearly always **your** wallet, not
        > your client's: nothing in this API drives a client's balance below
        > zero, while a reseller who stays in debt past his grace window has his
        > whole tenancy — his own domains and every client's — suspended. Top
        > your own wallet up and billing resumes, by itself, everything that
        > ladder stopped.

        Enabling a domain that is not disabled is a no-op, so a retry is safe.
      responses:
        '200':
          description: Enabled (or was never disabled)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Domain' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: Stopped by the system, not by you. Fix the underlying cause instead.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/InternalError' }
  # ---------------------------------------------------------------- Services

  /reseller/v1/services:
    get:
      tags: [Services]
      operationId: listServices
      summary: List services across all my clients
      description: |
        Every subscription belonging to every one of the caller's clients — the selling surface, so each row carries `plan_term_id`, the `price_rials` the client's wallet was debited, and the `id` you hand to `change-plan` and `DELETE /services/{serviceId}`. (`GET /subscriptions` is the same rows shaped for operating: expiry-sorted, filterable by days-to-expiry, no prices.)

        `client_id` is intersected with your own clients rather than trusted, so naming another reseller's client returns an empty page, not a 403 — and so does a `status` value outside the enum.

        Each row also reports the state of the domain behind the service (`domain_name`, `domain_disabled`, `domain_disabled_reason`, `domain_deleted`), read unscoped so a soft-deleted domain still resolves to a name instead of a bare id. **An `active` service can sit on a domain that is switched off, or on one that has been deleted**; read those fields before you report a customer as healthy. Newest first, `per_page` default 25, clamped to 100.
      parameters:
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PerPage' }
        - name: client_id
          in: query
          schema: { type: integer }
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/ServiceStatus' }
      responses:
        '200':
          description: A page of services
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Service' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Services]
      operationId: createService
      summary: Buy a plan for a client's domain
      description: |
        A **service is a subscription** — the existing `subscriptions` row. There
        is no separate entity, which is why WHMCS maps 1:1.

        **You pay; the client's wallet is only where the money lands.** The
        purchase debits the CLIENT's wallet at NSIN list price, and whatever that
        wallet is short by is moved out of YOUR wallet into his first, inside
        this same transaction (`ref_type: reseller_autofund` on both ledger
        rows). Top-up and purchase commit or roll back together, so a failed
        purchase can never leave you charged for a plan the client did not get.

        You do **not** have to pre-fund the client: `POST /clients/{clientId}/credit`
        is optional and only makes that automatic top-up smaller, or a no-op.

        There is no reseller branch anywhere in the pricing path —
        a reseller's client pays exactly what a direct customer pays. NSIN never
        learns, stores, or asks for the reseller's own retail price.

        Purchase is keyed on **`plan_term_id`**, not `plan_id`: a plan has several
        terms (30/90/365 days) at different prices. Get them from `GET /plans`.

        WHMCS `CreateAccount` maps here.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain_id, plan_term_id]
              properties:
                domain_id: { type: integer, examples: [412] }
                plan_term_id:
                  type: integer
                  description: From `GET /plans` → `terms[].id`. Not the plan id.
                  examples: [9]
                auto_renew: { type: boolean, default: false }
      responses:
        '201':
          description: |
            Purchased. The client's wallet was debited, having first been topped
            up from yours for whatever it was short — or, if this
            `Idempotency-Key` was already used for this exact request, the first
            call's response replayed unchanged, with no second purchase.
          headers:
            Idempotent-Replay:
              description: |
                Sent as `true` only on a replay: the body is the stored answer
                from the first call with this `Idempotency-Key`, and the
                operation did NOT run a second time. Absent on the call that
                actually did the work — which is how a retrying client tells
                which of its attempts moved the money.
              schema: { type: string, enum: ['true'] }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Service' }
        '400':
          description: |
            A malformed body, or a value the handler rejects outright.

            Also the answer to a **missing `Idempotency-Key`**: the generated
            wrapper rejects that before the handler is entered, so the body
            carries the wrapper's message rather than the handler's. A key longer
            than 191 bytes is rejected by the handler, with a message saying so.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: |
            **Your** wallet came up short, not the client's. Funding him for this
            purchase would take you past your credit limit — which, with the
            default `reseller_max_negative_rials` of `0`, means past zero. The
            body carries `"code": "credit_limit_reached"`.

            Crediting the client does not help: `POST /clients/{clientId}/credit`
            draws on the same wallet and fails for the same reason. Top your own
            wallet up through the panel's Zarinpal flow, or ask NSIN to raise the
            limit, then retry with the same `Idempotency-Key` — a failed purchase
            records no key.

            Nothing was written: no transfer, no ledger row, no subscription. (A
            402 with no `code` would be the client's own balance, which the
            automatic top-up makes unreachable on this path.)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: The domain already has an active service.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/services/{serviceId}:
    parameters:
      - { $ref: '#/components/parameters/ServiceId' }
    get:
      tags: [Services]
      operationId: getService
      summary: Get one service
      description: |
        `serviceId` is the numeric `subscriptions.id` — not a domain id and not a plan id. It is the one path parameter the tenancy middleware does not resolve, because a service is a billing row, so the handler proves ownership itself by joining `users.reseller_id`; a service that belongs to another reseller and one that never existed are the same **404**, on purpose.

        The body is the `Service` a row of `GET /services` carries, plan name, term and price included, plus `domain_disabled` / `domain_deleted` — the only place this call will tell you that the site behind a paid, `active` subscription is not actually being served.
      responses:
        '200':
          description: The service
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Service' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Services]
      operationId: cancelService
      summary: Terminate a service
      description: |
        Sets the subscription to `cancelled`, after closing the current billing
        period and invoicing the traffic already charged on it. No refund is
        issued for the unused remainder of the term — NSIN has no proration
        anywhere.

        > ### ⚠️ This is NOT the off switch.
        >
        > Cancelling leaves the domain **serving normally**, with no plan behind
        > it. It is a billing act, not a stop.
        >
        > It does **not**, however, run up a pay-as-you-go bill. A reseller's
        > client has **no PAYG at all** — traffic billing skips him entirely, and
        > the quota sweep stops a domain that has exhausted its allowance. A
        > domain with no subscription has an allowance of **zero**, so it is
        > stopped as soon as it serves a byte.
        >
        > So cancelling gives you a domain that **stops shortly**, at the next
        > sweep, with no bill. It does not give you a clean, immediate stop.
        >
        > **To stop a client immediately and unambiguously:
        > `POST /domains/{domainId}/disable`.**

        WHMCS `TerminateAccount` should **disable the domain first**, then call
        this.

        **No `Idempotency-Key` here, deliberately.** It is the one write in the
        Services family that does not take one, because it cannot benefit from
        one: cancelling is already idempotent — a subscription that is already
        `cancelled` short-circuits and answers `204` again without touching
        anything — so a retry after a dropped connection can neither cancel twice
        nor invoice twice. Requiring a key would be ceremony that buys nothing.
        Send one anyway if your client always does; it is ignored, not rejected.
      responses:
        '204': { description: Cancelled. The domain is still being served until the next quota sweep stops it. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/services/{serviceId}/change-plan:
    parameters:
      - { $ref: '#/components/parameters/ServiceId' }
    post:
      tags: [Services]
      operationId: changeServicePlan
      summary: Upgrade or downgrade a service
      description: |
        The same money path as `POST /services`: the new term is debited from the
        CLIENT's wallet at list price, and the shortfall is moved out of YOUR
        wallet into his inside the same transaction. You are the one who pays,
        and you do not have to pre-fund him.

        The full price of the new term is charged. NSIN has no proration
        anywhere, so nothing is refunded for the unused remainder of the term
        being left — a downgrade costs the price of the smaller plan, it does not
        pay anything back.

        WHMCS `ChangePackage` maps here.

        > **Not in v1:** the once-per-calendar-month plan-change cap. It is
        > deferred, so a reseller can currently change a plan as often as he
        > likes. See the Deferred table in `RESELLER_PLAN_V2.md`.
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan_term_id]
              properties:
                plan_term_id: { type: integer }
      responses:
        '200':
          description: |
            Plan changed — or, if this `Idempotency-Key` was already used for this
            exact request, the first call's response replayed unchanged, with no
            second plan change and no second debit.
          headers:
            Idempotent-Replay:
              description: |
                Sent as `true` only on a replay: the body is the stored answer
                from the first call with this `Idempotency-Key`, and the
                operation did NOT run a second time. Absent on the call that
                actually did the work — which is how a retrying client tells
                which of its attempts moved the money.
              schema: { type: string, enum: ['true'] }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Service' }
        '400':
          description: |
            A malformed body, or a value the handler rejects outright.

            Also the answer to a **missing `Idempotency-Key`**: the generated
            wrapper rejects that before the handler is entered, so the body
            carries the wrapper's message rather than the handler's. A key longer
            than 191 bytes is rejected by the handler, with a message saying so.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: |
            **Your** wallet came up short, not the client's: funding the new term
            would take you past your credit limit (by default, past zero). The
            body carries `"code": "credit_limit_reached"`. Top your own wallet up,
            or ask NSIN to raise the limit, then retry with the same
            `Idempotency-Key`. Nothing was written — the old plan is untouched and
            there is no partial ledger row.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
  # ---------------------------------------------------------------- Catalog

  /reseller/v1/plans:
    get:
      tags: [Catalog]
      operationId: listPlans
      summary: The plan catalog, with list prices
      description: |
        The **reseller** sees list prices here — he needs them to know what he is
        being charged, and to set his own retail price on his own website.

        His **clients** never see a price: the panel's plan endpoints strip every
        price field for a user with a `reseller_id`, and show plan name and
        features only.
      responses:
        '200':
          description: Active plans
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Plan' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  # ---------------------------------------------------------------- Usage

  /reseller/v1/usage:
    get:
      tags: [Usage]
      operationId: getUsage
      summary: Traffic and request usage
      description: |
        Aggregate, or per client via `client_id`.

        **This endpoint must stay bulk.** WHMCS calls `UsageUpdate` daily for
        *every* service; a per-service round trip does not scale and will time
        out on a reseller with a few hundred clients.
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: string, format: date }
        - name: to
          in: query
          required: true
          schema: { type: string, format: date }
        - name: client_id
          in: query
          schema: { type: integer }
      responses:
        '200':
          description: Usage rows
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/UsageRow' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '500': { $ref: '#/components/responses/InternalError' }
  # ---------------------------------------------------------------- Tokens

  /reseller/v1/tokens:
    get:
      tags: [Tokens]
      operationId: listApiTokens
      summary: List my API tokens
      description: |
        **Session (JWT) auth only.** An API token cannot list, mint, or revoke
        tokens — a leaked token must not be able to mint a second, quieter one.
      responses:
        '200':
          description: Tokens. The secret is never included.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ApiToken' }
        '403':
          description: Called with an API token instead of a session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Tokens]
      operationId: createApiToken
      summary: Mint an API token
      description: |
        **Session (JWT) auth only.**

        The full secret is returned **exactly once**, in this response, and is
        never recoverable. Only a bcrypt hash of it is stored. It must not appear
        in any other response body, and it must not appear in any log line.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, examples: ["WHMCS production"] }
                expires_at: { type: string, format: date-time, nullable: true }
      responses:
        '201':
          description: Minted. Store `secret` now — you will not see it again.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiToken'
                  - type: object
                    required: [secret]
                    properties:
                      secret:
                        type: string
                        description: Shown once. Never again.
                        examples: ["nsin_live_7f3a9c_9d2b41e8a7c04f16b5e3d8a91c6f2e70"]
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/tokens/{tokenId}:
    parameters:
      - name: tokenId
        in: path
        required: true
        schema: { type: integer }
    delete:
      tags: [Tokens]
      operationId: revokeApiToken
      summary: Revoke an API token
      description: '**Session (JWT) auth only.** Takes effect immediately; the next call with it gets 401.'
      responses:
        '204': { description: Revoked }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  # ------------------------------------------------------------ Diagnostics

  /reseller/v1/activity:
    get:
      tags: [Diagnostics]
      operationId: listApiActivity
      summary: List my own API calls
      description: |
        Every request **you** have made to this API, newest first, with the status
        and error it was answered with.

        This is your integration's log, not your clients' traffic — for the requests
        the edge served for their domains, see `/logs`. The two are unrelated, and
        this is the one to read when the *API itself* is not behaving.

        Failed authentication is recorded too, whenever the credential named a
        `key_id` that exists. That is deliberate and is the main reason this
        endpoint exists: a rejected token is the most common thing to get stuck on,
        and it is invisible everywhere else.

        Each row carries the `request_id` that was returned in the `X-Request-Id`
        response header of the call it describes. Quote it in a support ticket and
        it identifies the exact request.

        Rows are kept for `RESELLER_ACTIVITY_RETENTION_DAYS` (30 by default).
      parameters:
        - { name: page, in: query, schema: { type: integer, minimum: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100 } }
        - name: failed_only
          in: query
          description: Only calls answered 4xx or 5xx. The usual starting point.
          schema: { type: boolean }
        - name: status
          in: query
          description: Exact HTTP status, e.g. `401`.
          schema: { type: integer }
        - name: method
          in: query
          schema: { type: string, enum: [GET, POST, PUT, PATCH, DELETE] }
        - name: token_id
          in: query
          description: Only calls made with one credential — which of your integrations is misbehaving.
          schema: { type: integer }
        - name: request_id
          in: query
          description: The exact call, by the id from its X-Request-Id header.
          schema: { type: string }
        - name: since_hours
          in: query
          description: Only calls from the last N hours.
          schema: { type: integer, minimum: 1, maximum: 8760 }
      responses:
        '200':
          description: Your API calls, newest first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiActivityPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/diagnostics:
    get:
      tags: [Diagnostics]
      operationId: getDiagnostics
      summary: Check what my credential actually reaches
      description: |
        One authenticated round trip that reports what arrived: which credential,
        which reseller it resolved to, and what budget it is spending.

        Point a new integration here first. Reaching it at all proves four separate
        things that are otherwise diagnosed one painful step at a time — that the
        base URL is right, that TLS completed, that the credential parsed, and that
        it maps to the tenancy you expected.

        `base_url` is the origin this API is served from. If the host you called is
        not the one it names, that difference is the bug: the marketing site answers
        `/api/...` with a redirect and then an HTML 404, which reaches a JSON client
        as a parse error rather than as anything about the URL.
      responses:
        '200':
          description: What the server sees.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Diagnostics' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  # ---------------------------------------------------------------- Support

  /reseller/v1/support-contact:
    get:
      tags: [Support]
      operationId: getSupportContact
      summary: Get the support contact my clients see
      description: |
        Your own block, read from `users.support_contact` on the caller's row — not a client's, and there is no way to read another reseller's. It is what your clients receive on `GET /users/me` as `support_contact`, rendered by the panel in place of the ticket UI they do not have.

        This never 404s: a contact that was never configured comes back as `{}`, since every field is omitted when empty. That empty case is worth catching before you provision your first client — an empty block is dropped from the client's `/users/me` entirely, which leaves him with a Support section naming nobody to contact.
      responses:
        '200':
          description: The contact block
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SupportContact' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
    put:
      tags: [Support]
      operationId: setSupportContact
      summary: Set the support contact my clients see
      description: |
        A reseller's clients have **no tickets** — the ticket endpoints return 403
        for them. This block is rendered in the panel's Support section in place
        of the ticket UI, so the client contacts **his reseller**, not NSIN.

        Stored as JSON in `users.support_contact`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SupportContact' }
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SupportContact' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
# ==================================================================== Components

  # ------------------------------------------------------------ Fleet (all clients)
  #
  # Everything above this point addresses ONE client or ONE domain, which is the
  # right shape for provisioning and the wrong shape for operating: a reseller
  # with forty customers asking "what expires this month" had to make forty calls
  # and join the answers himself. These read the whole tenancy at once.
  #
  # They are scoped by the same tenancy helpers the single-object routes use, so
  # a fleet read can never see further than the sum of the reads it replaces.

  /reseller/v1/domains:
    get:
      tags: [Domains]
      operationId: listDomains
      summary: List every domain across all my clients
      description: |
        The fleet view. Note that the customer API's `GET /domains` deliberately
        does NOT include the domains you provide for your clients — it would bury
        your own domains under hundreds of theirs. This is where they live.
      parameters:
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PerPage' }
        - name: client_id
          in: query
          description: Narrow to one client. Still intersected with your tenancy.
          schema: { type: integer }
        - name: status
          in: query
          schema: { type: string, enum: [pending, active, moved, failed, disabled] }
        - name: q
          in: query
          description: Substring match on the domain name.
          schema: { type: string }
      responses:
        '200':
          description: A page of domains
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Domain' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/certs:
    get:
      tags: [SSL]
      operationId: listCerts
      summary: List TLS certificates across all my clients
      description: |
        Sorted by expiry ascending: the only question this page is opened to
        answer is "what is about to break", and that belongs at the top.

        Certificate PRIVATE KEYS are never returned here, on any plan.
      parameters:
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PerPage' }
        - name: status
          in: query
          schema: { type: string, enum: [pending, active, failed] }
        - name: expiring_days
          in: query
          description: Only certificates expiring within this many days.
          schema: { type: integer, minimum: 1, maximum: 3650 }
      responses:
        '200':
          description: A page of certificates
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Cert' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/subscriptions:
    get:
      tags: [Services]
      operationId: listSubscriptions
      summary: List domain subscriptions across all my clients
      description: |
        The OPERATING view of the same rows `GET /services` sells: what is live,
        what expires when, and whose it is. Kept separate rather than overloading
        one list with two sets of filters.

        Prices are absent by design — what you charge your customer is not
        something NSIN stores, and your wholesale cost belongs on your wallet
        rather than on every row of an operations table.
      parameters:
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PerPage' }
        - name: client_id
          in: query
          schema: { type: integer }
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/ServiceStatus' }
        - name: expiring_days
          in: query
          description: Only subscriptions expiring within this many days.
          schema: { type: integer, minimum: 1, maximum: 3650 }
      responses:
        '200':
          description: A page of subscriptions
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/DomainSubscription' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

        '500': { $ref: '#/components/responses/InternalError' }
  # ------------------------------------------------------- Analytics and logs
  #
  # A reseller is the operator of record for his clients' sites: he is the one
  # answering "why is my checkout 502-ing", and he cannot answer that from
  # aggregates. These endpoints therefore return the SAME raw request and WAF
  # rows the customer panel shows the domain's owner — end-user client IPs, user
  # agents and headers included. That is deliberate and matches what the market
  # sells (Gcore's reseller product exposes raw logs including Client IP).
  #
  # Everything here is scoped to the caller's tenancy by the same helper the
  # single-domain routes use. A reseller with no clients gets an empty page, not
  # everyone's traffic.
  #
  # There is no plan-feature gate on this surface, unlike the customer API's
  # /analytics/logs. That gate exists to sell an upgrade to the person reading
  # the panel; here the reader is the PROVIDER rather than the plan holder, and
  # gating would make a tenancy-wide query silently return a partial fleet.
  #
  # Analytics needs ClickHouse. When it is unreachable these endpoints answer
  # 503, exactly as the customer API's do — never a zeroed page, which would be
  # indistinguishable from "your traffic stopped".

  /reseller/v1/analytics/overview:
    get:
      tags: [Analytics]
      operationId: getAnalyticsOverview
      summary: Traffic across my whole tenancy
      description: |
        Per-domain traffic for every domain across every client, plus the
        roll-up over all of them.

        Both halves come back in one response on purpose: the dashboard shows
        the totals as headline figures and the rows as the table underneath, and
        two endpoints would guarantee the two disagree whenever a request lands
        between the calls.

        `totals.unique_visitors` is **not** the sum of the per-domain figures.
        Distinct visitor counts do not add — one person browsing three of your
        sites is one visitor — so it is computed over the whole tenancy.
      parameters:
        - { $ref: '#/components/parameters/Period' }
      responses:
        '200':
          description: Per-domain rows and the tenancy roll-up
          content:
            application/json:
              schema:
                type: object
                required: [data, totals]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/DomainTraffic' }
                  totals: { $ref: '#/components/schemas/TenancyTraffic' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/analytics/requests-compare:
    get:
      tags: [Analytics]
      operationId: getAnalyticsRequestsCompare
      summary: Request counts bucketed for day-over-day comparison
      description: |
        Request counts in whole-day-aligned buckets, for answering "is today
        worse than yesterday" — the question `/analytics/overview` cannot answer
        because its window is a rolling look-back, so today and yesterday are
        never the same shape.

        `granularity=hour` returns hourly buckets across the last `days` days,
        for overlaying one line per day. `granularity=day` returns one bucket
        per day. The window always starts at a **local midnight** so days are
        compared whole-day to whole-day; the newest day is partial by nature and
        the caller is expected to say so.

        Scope is the caller's whole tenancy, optionally narrowed to one domain
        with `domain_id`. The narrowing is an intersection: naming a domain that
        is not yours yields an empty series, never someone else's traffic. There
        is deliberately no "every request the edge saw" scope — that exists on
        the admin surface and includes hostnames belonging to nobody in this
        tenancy.
      parameters:
        - name: granularity
          in: query
          description: Bucket size. Anything but `day` is treated as `hour`.
          schema: { type: string, enum: [hour, day], default: hour }
        - name: days
          in: query
          description: |
            How many days back the window reaches, today included. Clamped to
            1..14 for hourly buckets (more lines than that is unreadable) and
            1..35 for daily — 35 is the ClickHouse row TTL, so nothing older
            exists to return.
          schema: { type: integer, minimum: 1, maximum: 35 }
        - name: domain_id
          in: query
          description: |
            Narrow to one domain. Still intersected with your tenancy, so naming
            another reseller's domain returns an empty series rather than his
            traffic.
          schema: { type: integer }
      responses:
        '200':
          description: One bucket per hour or per day, oldest first
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RequestSeries' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/analytics:
    get:
      tags: [Analytics]
      operationId: getDomainAnalytics
      summary: Traffic summary for one domain
      description: |
        Volume, error rate and response-time percentiles for a single domain.
        Served by the same handler the customer panel calls, so the two surfaces
        cannot report different numbers for the same domain.

        Latency figures exclude WebSocket requests: a WS `duration` spans the
        whole upgraded connection, so long-lived sockets would swamp the average
        and make it meaningless as a measure of how fast the site responds.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/Period' }
      responses:
        '200':
          description: The summary
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DomainAnalyticsSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  # The four below are what turns the per-domain page from four numbers into a
  # chart. Each is the customer API's own section handler reached through the
  # tenancy guard — same query, same section cache, same shape — because a
  # reseller and his client looking at the same domain on the same day must not
  # be able to read two different graphs off two implementations.

  /reseller/v1/domains/{domainId}/analytics/requests:
    get:
      tags: [Analytics]
      operationId: getDomainAnalyticsRequests
      summary: Requests over time for one domain
      description: |
        One bucket per hour (up to 24h) or per day (beyond), oldest first.
        Buckets with no traffic are absent rather than zero — the series is what
        the edge logged, not a padded calendar.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/Period' }
      responses:
        '200':
          description: The request series
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RequestSeries' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/analytics/bandwidth:
    get:
      tags: [Analytics]
      operationId: getDomainAnalyticsBandwidth
      summary: Bandwidth over time for one domain
      description: |
        Bytes in and out per bucket, on the same time ladder as
        `/analytics/requests`. Outbound is what was served to visitors — the
        figure the traffic bill is computed from.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/Period' }
      responses:
        '200':
          description: The bandwidth series
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BandwidthSeries' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/analytics/status-codes:
    get:
      tags: [Analytics]
      operationId: getDomainAnalyticsStatusCodes
      summary: Response status breakdown for one domain
      description: |
        Every status the domain returned in the period, with counts, busiest
        first. This is the detail behind the summary's single `error_rate`: a
        5% error rate that is all 404s and a 5% error rate that is all 502s are
        the same number and completely different problems.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/Period' }
      responses:
        '200':
          description: The status breakdown
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusCodeBreakdown' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/analytics/cache:
    get:
      tags: [Analytics]
      operationId: getDomainAnalyticsCache
      summary: Cache hit/miss/bypass for one domain
      description: |
        How much of the traffic the edge served without touching the origin.

        Requests with no cache status at all are excluded, so a domain that is
        entirely proxied reports zeros across the board rather than a
        100%-bypass rate it never measured.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/Period' }
      responses:
        '200':
          description: The cache breakdown
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CacheBreakdown' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/analytics/markdown-tester:
    get:
      tags: [Analytics]
      operationId: testDomainMarkdown
      summary: Preview the Markdown-for-Agents conversion for one domain
      description: |
        Fetches one page on the domain twice — once as a browser
        (`Accept: text/html`) and once as an agent (`Accept: text/markdown`) —
        through the public edge, and returns both responses so the panel can
        show a before/after diff and the token saving.

        This is the reseller's answer to "will turning this on break my
        customer's site". Without it the only way to find out is to enable
        `markdown_for_agents` on a live domain and look, which is a change to
        someone else's production site made in order to ask a question.

        There is no plan gate, matching the customer surface: previewing the
        conversion must be possible *before* the feature is bought.

        The fetch target is pinned to the authorized domain — `hostname` must be
        the domain itself or one of its subdomains, `path` must be a path and not
        a URL, redirects may not leave the domain, and bodies are capped at
        256 KiB. That containment is what keeps a preview endpoint from being an
        SSRF hole pointed at our own network.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - name: hostname
          in: query
          description: |
            Which host to fetch. Defaults to the domain apex. Must be the domain
            or one of its subdomains; anything else is a 400 rather than a fetch.
          schema: { type: string }
        - name: path
          in: query
          description: Path to fetch, starting with `/`. A full URL is rejected.
          schema: { type: string, default: '/' }
      responses:
        '200':
          description: Both fetches, side by side
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MarkdownTesterResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/logs:
    get:
      tags: [Logs]
      operationId: listLogs
      summary: Raw request logs across all my clients
      description: |
        Newest first. Every row is one request served at the edge, with the
        end-user's IP, user agent, headers, cache status, WAF score and origin
        response — the same rows the domain's owner sees.

        Beyond the parameters listed here the customer API's full filter set is
        accepted verbatim (`status`, `method`, `uri`, `cache`, `reqStatus`,
        `rayId`, `hostname`, `country`, `node`, `threat`, `botKind`,
        `wafRuleId`, ...); see the `/analytics/logs` entry in the customer
        reference. They are not repeated here because a second copy of a
        thirty-filter list is a second thing to keep in step.
      parameters:
        - { $ref: '#/components/parameters/Period' }
        - { $ref: '#/components/parameters/LogLimit' }
        - { $ref: '#/components/parameters/LogOffset' }
        - name: domain_id
          in: query
          description: |
            Narrow to one domain. Still intersected with your tenancy, so naming
            another reseller's domain returns an empty page rather than his logs.
          schema: { type: integer }
      responses:
        '200':
          description: A page of request logs
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LogPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/logs:
    get:
      tags: [Logs]
      operationId: listDomainLogs
      summary: Raw request logs for one domain
      description: |
        `GET /logs` narrowed to a single domain by path rather than by query, so
        a 404 tells an integrator he asked for a domain that is not his instead
        of an empty page that looks like a quiet site.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/Period' }
        - { $ref: '#/components/parameters/LogLimit' }
        - { $ref: '#/components/parameters/LogOffset' }
      responses:
        '200':
          description: A page of request logs
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LogPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/waf-logs:
    get:
      tags: [Logs]
      operationId: listWafLogs
      summary: WAF audit events across all my clients
      description: |
        Every request the WAF scored, blocked or would have blocked, newest
        first, with the matched rule ids, messages and the offending data.

        Separate from `/logs` because it answers a different question: `/logs`
        is "what happened", this is "what the firewall thought about it", and
        the row carries the per-rule detail that has nowhere to live in a
        request row.

        Additional filters are accepted verbatim from the customer API
        (`hostname`, `action`, `ruleId`, `clientIp`, `country`, `rayId`,
        `recordId`, `blocked`).
      parameters:
        - { $ref: '#/components/parameters/Period' }
        - { $ref: '#/components/parameters/LogLimit' }
        - { $ref: '#/components/parameters/LogOffset' }
        - name: domain_id
          in: query
          description: Narrow to one domain. Still intersected with your tenancy.
          schema: { type: integer }
      responses:
        '200':
          description: A page of WAF events
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WafLogPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/waf-logs:
    get:
      tags: [Logs]
      operationId: listDomainWafLogs
      summary: WAF audit events for one domain
      description: |
        The same WAF rows `GET /waf-logs` returns, pinned to one domain by the path.

        The scope is a single equality on the `domain_id` the guard resolved — a `domain_id` query parameter is not read on this route and cannot widen it — and `domain_id` covers **every hostname under the domain**, subdomains included, so narrow with `hostname` (substring match) or `recordId` (one proxied record) rather than expecting apex-only rows.

        `limit` defaults to 100 and **anything outside 1..500 falls back to 100, not to the nearest bound**, so `limit=1000` quietly returns a hundred rows; a negative `offset` becomes 0. `total` is the match count before paging, which is what you page through. `period` defaults to `24h` and an unrecognised value is treated as `24h` rather than rejected. Event timestamps are RFC 3339 UTC with a literal `Z`.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/Period' }
        - { $ref: '#/components/parameters/LogLimit' }
        - { $ref: '#/components/parameters/LogOffset' }
      responses:
        '200':
          description: A page of WAF events
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WafLogPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  # -------------------------------------------- Per-domain configuration
  #
  # Everything from here down is the customer API's own domain configuration,
  # re-addressed by domain id and mounted on this prefix. The SAME Go handlers
  # serve both surfaces — not a reimplementation, not a proxy — so a reseller
  # driving a domain through a machine token gets byte-identical behaviour to
  # its owner driving it through the panel, including validation, plan limits
  # and the PowerDNS/edge push.
  #
  # Request and response bodies are therefore identical to the equivalent
  # customer endpoint and are NOT restated here: a second copy of the DNS record
  # schema is a second thing to keep in step, and the copy is always the one
  # that goes stale. Read `openapi.yaml` (the customer reference) for the body
  # shapes; read this document for what a reseller may reach and how it is
  # scoped.
  #
  # Authorization is access.RoleProvider, resolved live from users.reseller_id:
  # a reseller is admin on every domain his clients own, minus billing. A
  # DISABLED domain is read-only and config writes answer 409 domain_disabled.

  /reseller/v1/domains/{domainId}/records:
    get:
      tags: [Records]
      operationId: listDomainRecords
      summary: List a domain's DNS records
      description: |
        The customer panel's `listRecords` (`GET /domains/{domain}/records/`), reached by numeric domain id — the identical Go function, so the row shape is defined in the customer reference and deliberately not restated here.

        Every record in one array, newest first; there is no pagination. It needs only `domain.view`, so it keeps answering on a **disabled** domain: reads stay open on purpose, because hiding a stopped customer's records would make a billing stop look like data loss.

        Watch `origin_rules` on proxied rows. When an origin route or an origin pool overrides a record, traffic does not go to the `destination` the row shows, and routes are listed before pools because that is the order the edge applies them.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Records]
      operationId: createDomainRecord
      summary: Create a DNS record
      description: |
        The customer panel's `createRecord`, reached by numeric domain id — same validation, same plan limits, same PowerDNS publish — so the body is the customer reference's `RecordCreate` and is not repeated here.

        **A domain with no active plan answers 402.** That is the trap in a provisioning script: a domain added through `POST /clients/{clientId}/domains` deliberately arrives without a plan, so records have to wait until `POST /services` has bought one. A full record quota is 403, a name and type that already exist is 400 `record exists` (not 409), and success is **200** with the created record, not 201.

        There is no `ttl` field: every record created here is written with a 120-second TTL, and `PUT` cannot change it either — only the import endpoints carry a TTL. If the zone push to PowerDNS fails the new row is rolled back and you get 502, so a failed create leaves nothing behind; a retry of a create that actually succeeded, on the other hand, comes back as `record exists` rather than repeating.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/records/{recordId}:
    put:
      tags: [Records]
      operationId: updateDomainRecord
      summary: Update a DNS record
      description: |
        The customer panel's `updateRecord`. A partial update — omitted fields keep their current value — so a client that reads the whole record and posts it back also re-posts every field it did not mean to touch. Body shape: the customer reference's `RecordUpdate`.

        `{recordId}` is resolved together with `{domainId}` in one query, so a record id belonging to another domain is a 404 rather than a cross-domain edit. There is no `ttl` field here; TTL is fixed when the record is created and only the import endpoints set it.

        Flipping `proxied` is not a metadata change: it rewrites what the zone publishes (the proxy IP replaces the destination) and pulls the hostname into the certificate coverage check. Repeating the same PUT is safe.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/RecordId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Records]
      operationId: deleteDomainRecord
      summary: Delete a DNS record
      description: |
        The customer panel's `deleteRecord`. It answers **200 with a message body, not 204**, and the delete is unscoped — the row is genuinely gone rather than tombstoned, so a second call answers 404 `record not found` and re-creating the name later gives a new record, not a restore.

        Removal from PowerDNS is best effort: if that push fails it is logged and you still get 200. Treat the call as accepted rather than confirmed in DNS, and read the record list back when it matters.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/RecordId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/records/scan:
    get:
      tags: [Records]
      operationId: scanDomainRecords
      summary: Discover a domain's existing records at its current DNS host
      description: |
        Probes the domain's live DNS and returns what it finds, without writing
        anything. This is the migration tool: it is what turns "point your NS at
        us" from a data-entry exercise into one click.

        **This is the slowest operation in the API, by a wide margin.** It makes
        roughly 1,450 live DNS queries — 290 candidate names against five record
        types — and how long that takes depends entirely on how fast the domain's
        current nameservers answer. Budget **seconds, not milliseconds**, and give
        this one call a client timeout far longer than you use for everything else
        here. A 5-second timeout will fail against a slow DNS host every time,
        while every other endpoint in this document answers in well under 100 ms
        of server time.

        The scan is bounded server-side (`DNS_SCAN_BUDGET_SEC`, 20s by default).
        If the nameservers are slow enough that the budget runs out, you get back
        **whatever was discovered by then** rather than an error — a partial list
        is useful, and the alternative was a request that ran for two and a half
        minutes into a connection the proxy had already closed.

        Nothing is written either way, so re-running it is free and is the right
        response to a result that looks short.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/records/scan-import:
    post:
      tags: [Records]
      operationId: scanImportDomainRecords
      summary: Scan the domain's current DNS host and import what is found
      description: |
        Scans the domain's DNS **at the nameservers it is delegated to right now** and imports what it finds in one shot, with no review step; `GET .../records/scan` is the same scan without the writing.

        What it scans is worth knowing before you trust the result. It tries an AXFR zone transfer against each authoritative nameserver first, and if none allows one — almost none do — it falls back to guessing: the apex plus roughly two hundred common labels (`www`, `mail`, `_dmarc`, `selector1._domainkey` and so on) queried for A, AAAA, CNAME, MX and TXT. So it finds the names it can guess, not everything in the zone, and a customer with unusual hostnames will arrive short of records. A wildcard zone is detected and collapsed into one `*` record instead of hundreds of synthesized ones.

        NS records are never imported, existing records are **skipped rather than overwritten** — that is the difference from `POST .../records/import` — and everything lands unproxied, so nothing moves to the edge until you flip `proxied`. The plan's record limit is checked first, so a domain with no plan answers 402. The handler reads nothing from the request body; send `{}`. You get back `created`, a per-record `failed` list, and the domain's full record list afterwards.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/records/import:
    post:
      tags: [Records]
      operationId: importDomainRecords
      summary: Import records from a zone file
      description: |
        This does not take a zone file. It takes a JSON `{"records": [...]}` list — normally exactly what `POST .../records/import/parse` handed back after you uploaded the file there. An empty list is 400.

        An entry whose name and type already exist **overwrites** it: destination, content, TTL and MX priority are refreshed while the existing `proxied` flag is deliberately preserved, so an import can never silently take a live record off the edge. TXT is the exception and is always additive, which means re-running the same import piles up duplicate TXT rows even though every other type simply turns from a create into an overwrite.

        Each item is processed independently — read `created`, `overwritten` and `failed[]` rather than the status code — and the plan's record limit is checked before anything is written, so a domain with no active plan answers 402.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/records/import/parse:
    post:
      tags: [Records]
      operationId: parseDomainRecordImport
      summary: Preview a zone file without importing it
      description: |
        Parses and validates, writes nothing. Call this first and show the user
        what is about to change — an import that silently replaces a live MX
        record is an outage the reseller gets blamed for.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/records/batch-update:
    post:
      tags: [Records]
      operationId: batchUpdateDomainRecords
      summary: Update many DNS records in one request
      description: |
        One request instead of one per record.

        This is not only a convenience. A per-record fan-out trips the edge rate
        limiter on any zone with a real number of records, and an integration
        migrating a customer in bulk hits that far harder than a human editing
        rows in a panel does — so the loop that looks obvious is the one that
        fails in production.

        Partial success is reported per record rather than rolled back: a zone
        where 48 of 50 updates applied is a state the caller needs to see
        described, not undone.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The domain is disabled, so its configuration cannot be changed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/records/batch-delete:
    post:
      tags: [Records]
      operationId: batchDeleteDomainRecords
      summary: Delete many DNS records in one request
      description: |
        POST rather than DELETE: the record ids travel in a body, and a DELETE
        with a body is not reliably forwarded by every proxy between a partner
        and this API.

        Same rate-limit reasoning as batch-update, and the same per-record
        result reporting.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The domain is disabled, so its configuration cannot be changed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/ssl:
    get:
      tags: [SSL]
      operationId: getDomainSsl
      summary: Certificate status for a domain
      description: |
        The customer panel's `getSslInfo`, so the body is defined in the customer reference: issuer, subject, SANs, key size, `status` (`none`, `pending`, `failed`, `expired`, `valid`), `days_remaining`, and `issued_at` / `expires_at` as RFC 3339.

        A read needs only `domain.view`, so it answers on a disabled domain, and the private key is never in the response — `has_private_key` is a boolean and that is the whole of it.

        The field worth automating on is `coverage`: proxied hostnames not yet on the certificate, with failure count and next retry. A `*.example.com` wildcard matches exactly one label, so `a.b.example.com` needs a name of its own and shows up here until it has one. On an external-DNS domain `coverage` is not computed at all and `can_manual_issue` is false with a reason — an empty `coverage` there means "not measured", not "fully covered".
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [SSL]
      operationId: uploadDomainSsl
      summary: Upload a customer-supplied certificate
      description: |
        Requires the domain's plan to include custom SSL. The private key is
        stored for the edge to fetch and is never returned by any endpoint in
        this document.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/ssl/parse:
    post:
      tags: [SSL]
      operationId: parseDomainSsl
      summary: Inspect a certificate PEM before uploading it
      description: |
        Returns the subject, SANs and validity window so the caller can show
        what he is about to install. Stores nothing.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/ssl/issue:
    post:
      tags: [SSL]
      operationId: issueDomainSsl
      summary: Force a certificate issuance now
      description: |
        Certificates are issued and renewed automatically. This is the escape
        hatch for the case that matters to support: the customer just fixed his
        delegation and does not want to wait for the next sweep.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/cache:
    delete:
      tags: [Cache]
      operationId: purgeDomainCache
      summary: Purge every cached entry for a domain
      description: |
        The blunt instrument, and the one a support call actually asks for: "I
        deployed and it still shows the old page".
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/cache/keys:
    get:
      tags: [Cache]
      operationId: listDomainCacheKeys
      summary: Browse a domain's cached entries
      description: |
        The customer panel's `listCacheKeys`: a page of the ClickHouse `cache_keys` registry — the index of what the edge wrote to L2, collapsed to each key's latest state and filtered to entries that are neither tombstoned nor expired. It is an index, not the cache; Kvrocks stays authoritative for what a purge actually removes.

        Every query parameter the customer endpoint takes passes straight through (`limit`, `offset`, `sort`, `dir`, `hostname`, `host`, `node`, `path`). `limit` defaults to 100 and anything outside 1..500 falls back to 100 rather than to the bound.

        Read the two host/path pairs carefully: `host` and `path` are the human-readable request URL, while `hostname`, `store_path`, `key_hash` and `node` are the stored identity that `POST /domains/{domainId}/cache/keys/purge` must have echoed back verbatim. `size` is bytes.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/cache/keys/summary:
    get:
      tags: [Cache]
      operationId: getDomainCacheSummary
      summary: Cached-entry totals and per-node breakdown
      description: |
        The customer panel's `getCacheKeysSummary`: entry count and `size_bytes` for the domain, broken down by the edge node that cached each object. `by_node` is a PoP, not a Kvrocks storage node, and the grand totals are summed from those rows so the two can never disagree.

        **It ignores the listing's filters.** The handler builds its registry filter from the domain id alone, so `hostname`, `host`, `node` and `path` change nothing here and a filtered browse cannot be totalled with this call — the customer reference claims otherwise and is wrong about it.

        Same registry as `GET /cache/keys`, and therefore the same 503 when that registry is not wired.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/cache/keys/purge:
    post:
      tags: [Cache]
      operationId: purgeDomainCacheKeys
      summary: Purge or refresh specific cached entries
      description: |
        Targeted invalidation by exact row or wildcard filter — the surgical
        version of `DELETE /cache`, for when dropping a whole site's cache to
        fix one stale asset would cost a traffic spike nobody budgeted for.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/members:
    get:
      tags: [Members]
      operationId: listDomainMembers
      summary: List who has access to a domain
      description: |
        Your client cannot manage his own domain's members — a managed owner
        must not sub-grant access to a domain his provider is funding — so you
        are the party who does it for him. His agency or developer is added
        here.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/members/{userId}:
    patch:
      tags: [Members]
      operationId: updateDomainMember
      summary: Change a member's role
      description: |
        Partial patch of one membership row — the customer API's `MemberUpdate` body: a `role`, and the three notification switches `notify_domain`, `notify_uptime`, `notify_ssl`. A body carrying none of them is 400. Only `admin`, `editor` and `viewer` may be granted: `viewer` reads the domain and its analytics, `editor` adds records, rules and cache, and `admin` adds settings, SSL **and** `members.manage` — so an admin member can invite and remove members himself. That last one is the power your client was denied; granting it hands it to a third party instead.

        Neither the owner nor you can be re-roled here. The owner's access comes from `domains.user_id` rather than a membership row (400), and your own `provider` row is a projection of `users.reseller_id` that the reconciler would restore, so changing its role is 409.

        Subscriptions start off for everyone who accepts an invite, which makes this the only way to switch a client's agency on to domain, uptime or SSL alerts. Switching your own on changes nothing you would notice: the owner's copy of those notifications is already redirected to you, and the fan-out collapses by resolved recipient. The response is the bare membership row, not the richer entry the member list returns.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/MemberUserId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Members]
      operationId: removeDomainMember
      summary: Remove a member's access
      description: |
        Deletes the membership row and nothing else — the person keeps his NSIN account and whatever access he holds on other domains.

        Two rows are unreachable. The owner cannot be removed (400), because his access is `domains.user_id` and not a membership; and neither can your own `provider` row — the delete is scoped `role <> provider`, so it comes back 404 "member not found". Detaching a client from you is an admin action (`PUT /admin/users/:id/reseller`), not a member removal.

        Not idempotent: a second call is 404, because nothing was deleted. Unlike records, rules, SSL and settings, this works on a disabled domain — managing members is not a configuration write, so nothing in the members and invites family answers 409.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/MemberUserId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/invites:
    get:
      tags: [Members]
      operationId: listDomainInvites
      summary: List outstanding invitations for a domain
      description: |
        Pending email invitations only, newest first, under an `invites` key — no pagination and no filter, so the entire pending set arrives in one response. Revoked, expired and consumed rows are excluded by the query, and an invite whose address already belongs to the owner or to a member is filtered out on top of that. This is therefore never a history of who was invited: accepting an invite makes it vanish from here and appear in the member list instead.

        Each entry carries the accept `link`, which is what you hand over when the invitation email does not arrive. It stays bound to the invited address, so forwarding it grants nobody anything.

        Invitations are created by `POST /reseller/v1/domains/{domainId}/invites` and live seven days by default (`DOMAIN_INVITE_TTL_HOURS` moves that; a per-invite `expires_in_hours` is silently clamped to 30 days).
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Members]
      operationId: createDomainInvite
      summary: Invite someone to a domain
      description: |
        Adding a member is not "insert a row": the person may not have an NSIN
        account yet, and this flow is what creates one. A member list you can
        read and prune but not add to is not member management.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/invites/{inviteId}:
    delete:
      tags: [Members]
      operationId: revokeDomainInvite
      summary: Revoke an invitation
      description: |
        Stamps `revoked_at` on the invitation, which kills its token immediately and drops it out of the pending list. Idempotent — revoking twice answers 200 both times.

        The trap is an invitation that was already accepted. The row is looked up by id regardless of its state, so you get the same cheerful `{"message": "revoked"}` while the membership it created is untouched and the person still has full access. Taking access away is `DELETE /reseller/v1/domains/{domainId}/members/{userId}`.

        `inviteId` is numeric and must belong to the domain in the path; anything else is 404.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/InviteId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/invites/{inviteId}/resend:
    post:
      tags: [Members]
      operationId: resendDomainInvite
      summary: Resend an invitation
      description: |
        Sends the invitation email again — email only, never SMS, and always to the invited address. It is one of the few messages about a client's domain that does **not** reach you: the invite mail bypasses the notification choke point where the reseller redirect lives, so the third party you are inviting receives it directly.

        This is not a re-send of the same link. The token is rotated and the expiry reset to the default TTL (seven days unless `DOMAIN_INVITE_TTL_HOURS` says otherwise, discarding whatever `expires_in_hours` the invite was created with), and both are saved *before* the mail is attempted. An SMTP failure therefore answers 500 with the previously mailed link already dead — recover by listing the invites again and handing over the fresh `link`, not by assuming nothing happened.

        Only a live invitation can be resent: revoked, expired or already accepted is 400.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/InviteId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  # ------------------------------------------------------------------- Rules
  #
  # The customer API mounts twelve near-identical route sets — twelve rule types
  # times seven operations, 84 endpoints with the type baked into the path.
  # Here the type is a PATH PARAMETER instead. Every type stays fully reachable
  # by a machine token; the document, the generated client and the handler
  # census are one seventh the size. An unknown type is 404, never an empty list
  # (which would tell an integrator his typo is a valid rule type with nothing
  # in it).

  /reseller/v1/domains/{domainId}/rules/{ruleType}:
    get:
      tags: [Rules]
      operationId: listDomainRules
      summary: List a domain's rules of one type
      description: |
        Every rule of one type on the domain, in evaluation order — `priority` ascending, ties broken by id, so rules left at the default priority evaluate in creation order. The response is a bare JSON array rather than an object, unpaginated, and `[]` when there is nothing.

        `ruleType` is one of `bot-route`, `cache`, `captcha`, `drop`, `error-page`, `fingerprint`, `origin_pool`, `origin_route`, `rate-limit`, `redirect`, `rewrite`, `waf`. Two of them are spelled with an underscore because these are the URL spellings the customer API has always used. Anything else is 404 `unknown_rule_type`, never an empty array.

        Listing needs only `domain.view`, so it keeps working on a disabled domain. Every write in this family — create, update, delete, toggle, reorder — needs `rules.edit` and answers 409 there instead.

        `error-page` is the one type whose list is lossy: it reports which status codes each rule has a page for, but not the HTML, which would otherwise turn a rules listing into a multi-megabyte response. Fetch a single rule for the bodies.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/RuleType' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Rules]
      operationId: createDomainRule
      summary: Create a rule
      description: |
        The body is the per-type one. This route runs the customer API's own create handler for the type, so the `POST /domains/{domain}/rules/<type>/` family in `openapi.yaml` is the reference for the shape. Every type shares `enabled`, `priority`, `record_id`, `host_pattern` with `host_match_type`, and `action_mode` — `enforce` or `dry_run`, where a dry-run rule matches and logs but lets the request through to the origin unchanged.

        `priority` defaults to 100, and 0 is read as "absent" rather than as the highest priority: a rule created with `"priority": 0` lands at 100 alongside everything else. Order it afterwards with `/reorder`.

        The plan is checked before anything is validated. A domain with no active plan is 402, and a plan that does not include rules — or whose rule quota for **this type** is already full, the quota being per type rather than per domain — is 403.

        Answers 200 with the created rule, not 201, and pings every active edge node to re-sync asynchronously; the 200 means the rule is stored, not that every node has already reloaded it.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/RuleType' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/rules/{ruleType}/reorder:
    patch:
      tags: [Rules]
      operationId: reorderDomainRules
      summary: Re-prioritise a domain's rules of one type
      description: |
        Rules of a type are evaluated in priority order, so reordering is a
        behaviour change, not a display preference.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/RuleType' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/rules/{ruleType}/{ruleId}:
    get:
      tags: [Rules]
      operationId: getDomainRule
      summary: Get one rule
      description: |
        One rule, in the same shape the list returns for that type.

        The lookup is scoped to the domain **and** the `ruleType` in the path. Rule ids are unique across all twelve types because they share one `rules` table, so an id taken from one type's list and fetched under another type's path is a 404 that reads like a deleted rule. Carry the type alongside the id.

        For `error-page` this is the only endpoint that returns the stored HTML and its sizes — the list gives you the status codes alone. Reading needs only `domain.view`, so a disabled domain still answers.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/RuleType' }
        - { $ref: '#/components/parameters/RuleId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    put:
      tags: [Rules]
      operationId: updateDomainRule
      summary: Update a rule
      description: |
        PUT, but a merge rather than a replace: only the keys present in the body are applied, and anything omitted keeps its stored value. You cannot clear a field by leaving it out, and you do not need to read the rule first in order to change one setting.

        Two differences from create are easy to trip over. There is no plan check here, so a rule can still be edited on a plan that would no longer let you create it; and `priority: 0` is honoured instead of being promoted to 100, which makes an update the only way to put a rule ahead of everything sitting at the default.

        The shared row and the per-type spec are saved in one transaction, so a rejected spec leaves neither changed. Validation failures are 400 with the reason in `error`.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/RuleType' }
        - { $ref: '#/components/parameters/RuleId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Rules]
      operationId: deleteDomainRule
      summary: Delete a rule
      description: |
        Removes the shared rule row and its per-type spec in one transaction. Permanent — rules have no soft delete and no undo, so `/toggle` is what you want when the rule may come back.

        Not idempotent: a second call is 404. Surviving rules keep their `priority` values, so a deletion leaves a gap and changes nothing else about evaluation order.

        409 on a disabled domain, unlike deleting the *domain*, which stays allowed while it is off. A rule edit changes what the edge serves, and accepting configuration for something nobody is serving would be a lie about what happened.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/RuleType' }
        - { $ref: '#/components/parameters/RuleId' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/rules/{ruleType}/{ruleId}/toggle:
    patch:
      tags: [Rules]
      operationId: toggleDomainRule
      summary: Enable or disable a rule
      description: |
        The safe way to take a rule out of the path while investigating: the
        specification is kept, so putting it back is one call rather than a
        re-entry from memory.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - { $ref: '#/components/parameters/RuleType' }
        - { $ref: '#/components/parameters/RuleId' }
      requestBody: { $ref: '#/components/requestBodies/CustomerApiPassthrough' }
      responses:
        '200': { $ref: '#/components/responses/CustomerApiPassthrough' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DomainDisabled' }
        '500': { $ref: '#/components/responses/InternalError' }
  # ---------------------------------------------------------------- Uptime
  #
  # Origin-outage detection, per domain. The customer panel reaches these at
  # `/uptime?domain=example.com`; the reseller reaches the same four handlers —
  # the same Go function values — under his own prefix with the domain in the
  # path. See outage/delegate.go.
  #
  # Why the reseller needs them at all: he is the operator of record for his
  # clients' sites. "Is it down, and since when" is the first question of every
  # support call he takes, and answering it from the request log means eyeballing
  # 5xx rows and guessing where sustained becomes an outage. The detector has
  # already made that judgement.

  /reseller/v1/domains/{domainId}/uptime:
    get:
      tags: [Uptime]
      operationId: listDomainOutageIncidents
      summary: Outage incident history for one domain
      description: |
        Sustained origin-outage incidents, most recent first. An incident opens
        when a subdomain's origin-error rate stays above this domain's threshold
        for the whole detection window, and resolves after `recover_min` clear
        minutes.

        Only sustained outages appear here. A site that 502s for ninety seconds
        and recovers is real but is not an incident — use `/uptime/live` for the
        current picture, including hosts that are erroring without having crossed
        the threshold.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
        - name: limit
          in: query
          description: Rows to return, 1–200. Anything outside that is treated as 50.
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
      responses:
        '200':
          description: The incident history
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OutageIncidentList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /reseller/v1/domains/{domainId}/uptime/live:
    get:
      tags: [Uptime]
      operationId: getDomainUptimeLive
      summary: Current origin-error status per subdomain
      description: |
        What is happening right now, per subdomain, over this domain's detection
        window — including hosts that are erroring but have not (yet) crossed the
        alert thresholds. Most alarming first: `down` hosts, then by error rate.

        `down` here means exactly what would fire an alert, computed the same way
        the detector computes it, so the panel cannot show "up" next to an open
        incident.

        With no analytics store behind it this answers `hosts: []` rather than an
        error, because "no host is erroring" and "we cannot see" are reported
        differently by `window_min` alone — see the note on the field.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      responses:
        '200':
          description: Live per-host status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UptimeLive' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: The analytics store could not be queried.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /reseller/v1/domains/{domainId}/uptime/settings:
    get:
      tags: [Uptime]
      operationId: getDomainUptimeSettings
      summary: Get outage-detection settings for one domain
      description: |
        This domain's detection thresholds, with the valid range for each in
        `bounds`.

        The values are the CLAMPED ones — what the detector will actually use —
        not the raw column values. A window stored above the deployment's cap is
        reported at the cap, so the panel never shows a setting that is not in
        force.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      responses:
        '200':
          description: Current settings
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UptimeSettings' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    put:
      tags: [Uptime]
      operationId: updateDomainUptimeSettings
      summary: Update outage-detection settings for one domain
      description: |
        Partial update — omitted fields keep their current value. Out-of-range
        values are REJECTED with a 400 naming the field, not silently clamped: a
        threshold that was quietly changed to something else is a setting the
        operator believes he made and did not.

        Unlike records, rules, cache and SSL, this is accepted on a DISABLED
        domain. Alert configuration is not edge configuration — nothing here
        changes what is served — and a provider settling a domain's alerting
        while it is switched off is doing something reasonable.
      parameters:
        - { $ref: '#/components/parameters/DomainId' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UptimeSettingsUpdate' }
      responses:
        '200':
          description: The settings as they now stand
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UptimeSettings' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

        '500': { $ref: '#/components/responses/InternalError' }
components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <credential>` — **two kinds of credential, one header.**

        The middleware distinguishes them by prefix:

        | Credential | Used by | How it's checked |
        |---|---|---|
        | `nsin_live_<keyid>_<secret>` | machine clients (WHMCS, the reseller's own site) | look up `key_id`, verify the secret against its bcrypt hash |
        | anything else | the reseller dashboard | parsed as a session JWT, exactly like the admin panel |

        Either way the handler ends up with the same `callerID`, and every handler
        then calls `mustOwnClient` / `mustOwnDomain` before touching a row.

        Token endpoints are the one exception: they require the **JWT** form, so a
        leaked API token cannot mint another one.

  parameters:
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    PerPage:
      name: per_page
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    ClientId:
      name: clientId
      in: path
      required: true
      description: Must be a client of the caller, or the response is 403.
      schema: { type: integer }
    DomainId:
      name: domainId
      in: path
      required: true
      description: Must belong to a client of the caller, or the response is 403.
      schema: { type: integer }
    ServiceId:
      name: serviceId
      in: path
      required: true
      description: Must belong to a client of the caller, or the response is 403.
      schema: { type: integer }
    RecordId:
      name: recordId
      in: path
      required: true
      description: Must belong to the domain in the path.
      schema: { type: integer }
    RuleType:
      name: ruleType
      in: path
      required: true
      description: |
        Which rule subsystem. Note the mixed separators: they are the URL
        spellings the customer API has always used, and changing them here to
        look tidier would mean two spellings for one thing.
      schema:
        type: string
        enum: [bot-route, cache, captcha, drop, error-page, fingerprint,
               origin_pool, origin_route, rate-limit, redirect, rewrite, waf]
    RuleId:
      name: ruleId
      in: path
      required: true
      description: Must belong to the domain and rule type in the path.
      schema: { type: integer }
    MemberUserId:
      name: userId
      in: path
      required: true
      description: The member's user id, as returned by the member list.
      schema: { type: integer }
    InviteId:
      name: inviteId
      in: path
      required: true
      schema: { type: integer }
    Period:
      name: period
      in: query
      description: |
        Look-back window. Anything unrecognised is treated as 24h rather than
        rejected, so a dashboard cannot break itself with a stale value.
      schema: { type: string, enum: [3h, 6h, 12h, 24h, 7d, 30d], default: 24h }
    LogLimit:
      name: limit
      in: query
      description: Rows per page. Values outside 1..500 fall back to 100.
      schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
    LogOffset:
      name: offset
      in: query
      schema: { type: integer, minimum: 0, default: 0 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        **Required on every endpoint that moves money.**

        A client-generated unique string (a UUID is fine). Replaying the same key
        returns the **original** response byte for byte and does **not** repeat
        the operation. A replayed response is marked with the response header
        `Idempotent-Replay: true`.

        This is not optional politeness. Without it, a network timeout on a
        `credit` or `createService` call leaves the caller unable to tell whether
        the money moved, and a naive retry double-charges.

        **A key never expires.** There is no TTL and nothing prunes the table: the
        record is written inside the same transaction as the money movement and
        then kept forever, so a key is burned permanently. The same key sent a
        year later still replays the original answer instead of transferring
        again. Generate one key per operation — never one per process, per day, or
        per client.

        Keys are scoped to the calling reseller, so two partners may pick the same
        UUID without colliding, and neither can probe the other's key space.

        Reuse with a **different** request is refused rather than replayed: same
        key, different method, path or body is `422`. See the
        `IdempotencyConflict` response.

        A **failed** operation records nothing, because the record and the money
        share one transaction and roll back together. A transfer that bounced for
        insufficient funds is therefore freely retryable with the *same* key once
        the wallet is funded.

        The limit is **191 characters**, not the 128 this document used to claim —
        it is the width of the database column, and it is the only validation
        performed on the value. The check runs on the trimmed string and counts
        bytes, so a non-ASCII key runs out before 191 characters; over-length is
        `400`.
      schema: { type: string, maxLength: 191 }

  responses:
    BadRequest:
      description: Malformed or invalid input.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, malformed, expired, or revoked credential.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: |
        Authenticated, but the target does not belong to you — or you are not a
        reseller at all.

        This is the response the tenant-isolation matrix asserts on. Every route
        in this document is called with reseller B's credential against reseller
        A's ids, and must answer 403 or 404 with **none** of A's data in the body.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    AnalyticsUnavailable:
      description: |
        The analytics store (ClickHouse) is unreachable.

        Deliberately an error rather than a zeroed page: a page of zeros is
        indistinguishable from "your customers' traffic stopped", and someone
        would act on it.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    DomainDisabled:
      description: |
        The domain is switched off and is not being served, so its configuration
        cannot be changed. It can still be read, and writes work again once it is
        enabled.

        409 rather than 403 on purpose: you have every right to do this, the
        domain is simply in a state that cannot accept it, and telling a partner
        "you don't have permission" would send him to entirely the wrong
        conclusion. The body carries `"code": "domain_disabled"`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    CustomerApiPassthrough:
      description: |
        The customer API's response for the equivalent endpoint, unchanged.

        These operations mount the SAME Go handlers the customer panel calls, so
        the body shape is defined there and is deliberately not restated here — a
        second copy of the DNS record or rule schema is a second thing to keep in
        step, and the copy is always the one that goes stale. See `openapi.yaml`.
      content:
        application/json:
          schema: { type: object, additionalProperties: true }
    InternalError:
      description: |
        Something failed on our side. The body carries a human sentence and never
        an internal detail — a live sweep of this API once returned
        `dial tcp 127.0.0.1:9000: connect: connection refused`, which is our
        topology rather than an error message. That is now impossible.

        **Distinguish it from `503`.** A 503 means a dependency is down and the
        identical request will succeed later, so retry it. A 500 means the request
        hit a genuine fault: retrying it unchanged will fail the same way, and it
        should be reported with the `X-Request-Id` from the response header.

        Declared on every operation because every operation can reach it. It was
        previously declared on exactly one, which left a generated client with no
        branch for the answer it is most likely to be surprised by.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: No such resource.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooManyRequests:
      description: |
        The caller's request budget is spent. **300 requests per minute** by
        default (`RESELLER_RATE_LIMIT`), and the same limiter covers every one of
        the 83 operations in this document.

        Counted per **credential**, not per reseller: each `nsin_live_` token has
        its own budget and a dashboard session has another, so one runaway
        integration cannot lock its owner out of his own panel, and revoking that
        token is enough to stop it.

        Branch on `"code": "rate_limited"` in the body. The message beside it is
        written for a human and may be reworded.

        `Retry-After` is set, in seconds until the window resets — wait that long
        rather than retrying at once. The `X-RateLimit-*` headers are **not** on
        this response; they appear only on the responses the limiter let through,
        so a client that reads its remaining budget from the 429 alone will never
        see one.
      headers:
        Retry-After:
          description: Seconds until the current window resets.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    IdempotencyConflict:
      description: |
        This `Idempotency-Key` has already been used, for a **different** request.

        A replay is only a replay if the method, path and body all match: they are
        hashed together into a fingerprint stored beside the recorded response,
        and a key that comes back with a different fingerprint is a caller bug —
        most often a key generated once per process instead of once per operation.

        422 rather than 409 because the request is well-formed and authorized and
        no resource is in a conflicting state; it is the key that cannot be
        processed. Replaying the stored answer instead would tell the caller his
        SECOND transfer succeeded when it never ran, and he would report a client
        as funded who is not.

        Nothing here is retryable until the key changes. The original operation
        stands, the new one never ran, and the fix is a fresh key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  requestBodies:
    CustomerApiPassthrough:
      description: |
        The customer API's request body for the equivalent endpoint, unchanged.
        See `openapi.yaml`; it is not restated here for the same reason the
        response is not.
      required: true
      content:
        application/json:
          schema: { type: object, additionalProperties: true }

  schemas:

    Error:
      type: object
      description: |
        Every error body carries `error`, a human-readable sentence. Some also
        carry `code`, a stable machine-readable reason — branch on that, never on
        the sentence, which is prose and gets reworded.
      required: [error]
      properties:
        error: { type: string, examples: ["forbidden"] }
        code:
          type: string
          description: |
            Present only on the failures worth branching on, and deliberately not
            an exhaustive enum: treat a code you do not recognise as if it were
            absent and fall back to the status code.

            The ones that exist today:

            * `credit_limit_reached` — 402 from `POST /services` and
              `POST /services/{serviceId}/change-plan`. **Your** wallet, not the
              client's: funding the purchase would take you past your overdraft
              (default `0`, i.e. past zero). Top up; do not credit the client.
            * `domain_disabled` — 409 from a configuration write on a disabled
              domain (records, rules, cache, SSL, members, settings). The domain
              is read-only until `POST /domains/{domainId}/enable`.
            * `unknown_rule_type` — 404 from the rules paths when `ruleType` is
              not one of the documented values. You get this rather than an empty
              array, so a typo cannot read as a rule set that happens to be empty.
            * `rate_limited` — 429 from any route. Keyed per credential, so one
              runaway integration cannot lock its owner out of the dashboard.
            * `session_check_failed` — 503, and only on calls made with a
              dashboard session JWT. The session could not be verified, which is
              not the same as knowing it is revoked, so it is retryable and the
              session survives. Machine tokens never see this.
          examples: ["credit_limit_reached"]

    PageMeta:
      type: object
      required: [page, per_page, total]
      properties:
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }

    Client:
      type: object
      description: An end user owned by the calling reseller.
      required: [id, email, phone_number, active, phone_verified, created_at]
      properties:
        id: { type: integer }
        name: { type: string, nullable: true }
        email: { type: string, format: email }
        phone_number: { type: string, examples: ["09123456789"] }
        phone_verified:
          type: boolean
          description: |
            Whether the panel will accept this number without challenging it.
            `true` for a freshly provisioned client — you vouched for it. See
            `phone_verified_by` for the basis.
        phone_verified_by:
          type: string
          enum: ["", "otp", "provider", "admin"]
          description: |
            How the number came to be verified.

            * `otp` — the client consumed a one-time code sent to this exact
              number. Proof that somebody holds the handset.
            * `provider` — you entered it and vouched for it. Trust in you, not
              proof of the handset.
            * `admin` — set by NSIN staff.
            * `""` — not verified.

            Changing a client's phone via `PATCH /clients/{clientId}` re-bases
            this on `provider`: an OTP consumed against the old number proves
            nothing about the new one, but your vouching still applies.
        national_code: { type: string, examples: ["0012345678"] }
        birth_date: { type: string, format: date, nullable: true }
        active:
          type: boolean
          description: '`false` = account disabled; cannot log in.'
        wallet_balance_rials:
          type: integer
          format: int64
          description: |
            Visible to the reseller. The client is never shown this, and gets 403
            from every wallet endpoint.
        created_at: { type: string, format: date-time }

    CreateClientRequest:
      type: object
      description: |
        These are **exactly** the fields the public `POST /auth/register` requires
        and validates ([auth.go:251-267]), minus `otp_code`. The reseller must
        supply real identity data — the validators are checksum-level, not
        length checks, so placeholders are rejected.

        The only difference from public registration: **no `otp_code`**. There is
        nobody at the reseller's end to type one.
      required: [email, password, phone_number, national_code]
      properties:
        email:
          type: string
          format: email
          description: Required. Normalized (gmail dots, case) before the uniqueness check.
        password:
          type: string
          format: password
          description: |
            **Required** — `auth.register` rejects an empty password. The reseller
            sets it, and can change it later via `POST /clients/{id}/password`.
        phone_number:
          type: string
          description: |
            **Required.** Iranian mobile, validated with `ValidIranianPhone` and
            normalized with `NormalizeIranianPhone`.
          examples: ["09123456789"]
        national_code:
          type: string
          description: |
            **Required.** Validated with `ValidIranianNationalCode` — a real
            checksum, not a length check. A made-up 10-digit string is rejected.
          examples: ["0012345678"]
        name:
          type: string
          description: Optional.
        birth_date:
          type: string
          format: date
          description: |
            Optional — an empty value is accepted (`parseBirthDate` returns
            `"", nil`). If present it must be `YYYY-MM-DD`.
        timezone:
          type: string
          description: Optional.
          examples: ["Asia/Tehran"]

    UpdateClientRequest:
      type: object
      description: Every field optional; omitted fields are unchanged.
      properties:
        name: { type: string }
        email: { type: string, format: email }
        phone_number: { type: string }
        birth_date: { type: string, format: date }

    TransferResult:
      type: object
      required: [reseller_balance_rials, client_balance_rials, amount_rials]
      properties:
        amount_rials: { type: integer, format: int64 }
        reseller_balance_rials:
          type: integer
          format: int64
          description: My balance after the transfer.
        client_balance_rials:
          type: integer
          format: int64
          description: The client's balance after the transfer.

    WalletTransaction:
      type: object
      description: |
        One row of the append-only ledger. Historical rows are never updated — a
        correction is a new, compensating entry.
      required: [id, type, amount_rials, balance_after, created_at]
      properties:
        id: { type: integer }
        type:
          type: string
          description: e.g. `recharge`, `traffic`, `subscription`, `transfer_in`, `transfer_out`.
        amount_rials:
          type: integer
          format: int64
          description: Negative for a debit, positive for a credit.
        balance_after: { type: integer, format: int64 }
        description: { type: string }
        created_at: { type: string, format: date-time }

    Domain:
      type: object
      required: [id, name, status, user_id, suspended]
      properties:
        id: { type: integer }
        name: { type: string, examples: ["example.com"] }
        user_id: { type: integer, description: The owning client. }
        status:
          type: string
          enum: [pending, active, moved, disabled]
          description: |
            NS-delegation state, driven by the checker — **not** a billing state.
            `moved` means the nameservers stopped pointing at NSIN.
        suspended:
          type: boolean
          description: |
            Billing/administrative stop. **This, not `status`, is what decides
            whether the site is served.** Set by the negative-balance ladder, or
            manually via `POST /domains/{domainId}/disable`.
        suspended_at: { type: string, format: date-time, nullable: true }
        suspended_reason:
          type: string
          enum: ["", manual, quota_exceeded, negative_balance]
          description: |
            WHY the domain is suspended. Empty when `suspended` is false.

            **Read this before offering a resume button.** Only `manual` can be
            lifted with `POST /domains/{domainId}/enable`; the other two are
            the system's own and that call returns **409**:

            - `manual` — you switched it off. Nothing auto-resumes it, which is
              the point: a client suspended for not paying must not come back
              online because a quota period rolled over.
            - `quota_exceeded` — the client used up his plan's included traffic.
              A reseller's client has no pay-as-you-go, so this is his hard stop.
              Resolve it by moving him to a bigger plan; the quota sweep then
              resumes the domain by itself.
            - `negative_balance` — a wallet stayed negative past its grace
              window. On a client's domain that is usually **yours**, not his:
              the tenancy sweep suspends every domain under a reseller who is in
              debt, and a client's own balance has nothing that can drive it
              below zero. Top your balance back up and billing resumes the
              domains it suspended.
        dns_mode: { type: string, enum: [managed, external] }
        created_at: { type: string, format: date-time }

    Cert:
      type: object
      description: |
        One TLS certificate row, as it applies to a single hostname. A domain
        normally has two: the apex and its wildcard sibling.

        Deliberately a projection, not the stored row: private keys, ACME
        account references and issuance internals are never exposed here, so
        adding a column to the certs table cannot silently widen this response.
      required: [id, domain_id, domain, hostname, status, expires_at]
      properties:
        id: { type: integer }
        domain_id: { type: integer }
        domain:
          type: string
          description: The registrable domain this certificate belongs to.
          examples: ["example.com"]
        hostname:
          type: string
          description: The exact SAN this row covers.
          examples: ["example.com", "*.example.com"]
        status:
          type: string
          enum: [pending, active, failed]
          description: '`pending` while issuing; `failed` after the CA refused the name.'
        expires_at: { type: string, format: date-time }

    DomainSubscription:
      type: object
      description: |
        A domain subscription seen from the operating side: what is live, when it
        expires, and whose it is. The same underlying row as `Service`, without
        the selling fields.
      required: [id, domain_id, domain_name, client_id, plan_id, status, auto_renew]
      properties:
        id: { type: integer }
        domain_id: { type: integer }
        domain_name: { type: string, examples: ["example.com"] }
        client_id: { type: integer }
        plan_id: { type: integer }
        plan_name: { type: string, nullable: true }
        status: { $ref: '#/components/schemas/ServiceStatus' }
        auto_renew: { type: boolean }
        started_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }

    ServiceStatus:
      type: string
      enum: [active, grace, expired, cancelled]
      description: |
        Mirrors `subscriptions.status` exactly.

        **There is deliberately no `suspended` value.** Suspension is not a
        subscription state in NSIN — it is `domains.suspended`, a boolean on the
        domain, and that is the thing the edge actually checks before serving.

        So: `expired` and `cancelled` mean *"no plan, still serving"*. They are
        **not** an off switch. The off switch is
        `POST /domains/{domainId}/disable`.

        A domain in that state has an allowance of zero and — because a
        reseller's client has no pay-as-you-go — is **suspended by the quota
        sweep** as soon as it serves a byte. It stops shortly; it does not run
        up a bill. But "shortly" is not "now", which is why the explicit domain
        suspend exists.

        > **TODO (deferred):** a first-class `suspended` status on the
        > subscription itself, so a service can be frozen without touching the
        > domain row. Not needed for v1 — domain suspend already stops serving
        > and stops the meter, which is the entire business requirement. See the
        > Deferred table in `RESELLER_PLAN_V2.md`.

    Service:
      type: object
      description: |
        A subscription. Not a new entity — this *is* the `subscriptions` row.

        The plan is reported three ways on purpose, so a reseller can always see
        **which plan is active and what it cost** without a second lookup:
        `plan_id` + `plan_name` (which plan), `duration_days` (which term), and
        `price_rials` (what the client's wallet was actually debited).
      required:
        [id, client_id, domain_id, plan_id, plan_name, plan_term_id,
         duration_days, price_rials, status, started_at, expires_at]
      properties:
        id: { type: integer }
        client_id: { type: integer }
        domain_id: { type: integer }
        domain_name: { type: string, examples: ["example.com"] }
        domain_disabled:
          type: boolean
          description: |
            Whether the domain behind this service is currently being served.

            **A service can be `active` while its domain is off.** The plan is
            paid for; the traffic is not flowing. Read this before reporting a
            service as healthy.
        domain_disabled_reason:
          type: string
          enum: [manual, quota_exceeded, negative_balance]
          description: |
            Present only when `domain_disabled` is true. `manual` is yours to
            lift with `POST /domains/{domainId}/enable`; the other two are the
            system's and that call returns 409.
        plan_id:
          type: integer
          description: Which plan is active. Free / Starter / Golden / Enterprise.
          examples: [3]
        plan_name: { type: string, examples: ["Golden"] }
        plan_term_id: { type: integer, examples: [14] }
        duration_days:
          type: integer
          description: The term that was bought. The same plan at a different duration is a different price.
          examples: [365]
        price_rials:
          type: integer
          format: int64
          description: |
            NSIN list price of this term — what the **client's** wallet was
            debited at purchase. `0` for the Free plan, which is a real,
            purchasable plan (the debit is simply a no-op).
          examples: [120000000]
        status: { $ref: '#/components/schemas/ServiceStatus' }
        started_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        grace_until: { type: string, format: date-time, nullable: true }
        auto_renew: { type: boolean }

    Plan:
      type: object
      required: [id, name, slug, terms]
      properties:
        id: { type: integer }
        name: { type: string }
        title_fa: { type: string }
        slug: { type: string }
        description: { type: string }
        max_domains: { type: integer, nullable: true, description: 'null = unlimited' }
        max_records: { type: integer, nullable: true }
        max_traffic_gb:
          type: integer
          nullable: true
          description: |
            Included traffic per quota period.

            For a **direct** NSIN customer, usage beyond this is billed
            pay-as-you-go and may drive the balance negative.

            For a **reseller's client it is a hard cap, not a meter.** He has no
            pay-as-you-go: traffic billing skips him entirely, and when he
            reaches this number the quota sweep **suspends his domain**. He is
            never charged for overage, and his wallet is never driven negative
            by traffic. Buy him a bigger plan to lift the cap.

            The cap is enforced at the next sweep, not at the byte, so a client
            can overshoot by whatever he serves within one ticker interval.
        max_bandwidth_gb: { type: integer, nullable: true }
        max_requests_per_month: { type: integer, nullable: true }
        logs_enabled: { type: boolean }
        monitoring_enabled: { type: boolean }
        rules_enabled: { type: boolean }
        cache_purge_enabled: { type: boolean }
        custom_ssl_enabled: { type: boolean }
        ws_enabled: { type: boolean }
        host_header_edit_enabled: { type: boolean }
        dedicated_support_enabled: { type: boolean }
        max_rules_per_set: { type: integer, nullable: true }
        max_cache_cap_mb:
          type: integer
          description: The largest per-domain cache cap this plan permits. Never null — the floor is 128.
        is_recommended: { type: boolean }
        auto_renew_allowed:
          type: boolean
          description: When false, wallet auto-renew cannot be enabled and the renew job skips the subscription.
        sort_order: { type: integer, description: Catalog display order. }
        platform_features_total:
          type: integer
          description: How many platform capability flags exist, so `platform_features_enabled` reads as "N of M".
        platform_features_enabled:
          type: integer
          description: How many of them this plan turns on.
        terms:
          type: array
          description: |
            Purchase targets. `POST /services` takes a **term** id, not a plan id.

            **Always present, and always an array.** A plan with no active term
            comes back as `[]` rather than as a missing key or `null` — it is a
            plan you cannot currently sell, not a plan without the field. Ordered
            shortest duration first, which is also the baseline `discount_percent`
            is measured against.
          items: { $ref: '#/components/schemas/PlanTerm' }

    PlanTerm:
      type: object
      required: [id, duration_days, price_rials]
      properties:
        id: { type: integer }
        duration_days: { type: integer, examples: [30, 90, 365] }
        price_rials:
          type: integer
          format: int64
          description: |
            NSIN **list** price — what the client's wallet is debited. The reseller
            sees it; the client never does. NSIN does not know, store, or ask for
            the reseller's own retail price.
        is_default: { type: boolean }
        grace_days:
          type: integer
          description: Days after expiry before the subscription is treated as lapsed.
        sort_order: { type: integer }
        discount_percent:
          type: integer
          description: |
            Saving against the **shortest** active term's daily rate, as a whole
            percent. `0` on that shortest term itself, and on any term that is not
            actually cheaper per day. Computed, not stored — it is what a reseller
            prices a longer commitment against.

    UsageRow:
      type: object
      required: [client_id, domain_id, domain_name]
      properties:
        client_id: { type: integer }
        domain_id: { type: integer }
        domain_name: { type: string }
        cached_bytes: { type: integer, format: int64 }
        proxied_bytes: { type: integer, format: int64 }
        direct_bytes: { type: integer, format: int64 }
        requests: { type: integer, format: int64 }
        charged_rials: { type: integer, format: int64 }

    ApiToken:
      type: object
      description: The plaintext secret appears here **only** in the mint response.
      required: [id, name, key_id, status, created_at]
      properties:
        id: { type: integer }
        name: { type: string }
        key_id:
          type: string
          description: The public half. Safe to display and log.
          examples: ["7f3a9c"]
        status: { type: string, enum: [active, revoked] }
        expires_at: { type: string, format: date-time, nullable: true }
        last_used_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    ApiCall:
      type: object
      description: One request you made to this API, and the answer it got.
      required: [id, created_at, request_id, method, path, status, duration_ms, ip]
      properties:
        id: { type: integer }
        created_at: { type: string, format: date-time }
        request_id:
          type: string
          description: The `X-Request-Id` returned with this call. Quote it in a ticket.
          examples: ["9f2b41e8a7c04f16"]
        token_id:
          type: integer
          description: The credential used. Absent when the call came from a dashboard session.
        token_name: { type: string, examples: ["WHMCS production"] }
        method: { type: string, examples: ["POST"] }
        path:
          type: string
          description: What you asked for, query string included.
          examples: ["/reseller/v1/clients?page=2"]
        route:
          type: string
          description: |
            The route template it matched. **Empty means it matched nothing** —
            which is the answer when a 404 is really a wrong path.
          examples: ["/reseller/v1/clients/:clientId"]
        status: { type: integer, examples: [401] }
        duration_ms:
          type: integer
          description: Server-side handling time. Excludes network and TLS, so a client-side timeout far larger than this one means the time went to the wire, not to us.
        error:
          type: string
          description: The `error` field of the response. Present only for failures.
          examples: ["Not Authorized"]
        ip: { type: string }
        user_agent: { type: string }

    ApiActivityPage:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/ApiCall' }
        meta: { $ref: '#/components/schemas/PageMeta' }

    Diagnostics:
      type: object
      description: What the server sees when your integration calls it.
      required: [ok, base_url, reseller_id, auth, rate_limit, server_time, request_id]
      properties:
        ok: { type: boolean }
        base_url:
          type: string
          description: The origin this API is served from. Compare it with the host you called.
          examples: ["https://api.nsin.cloud"]
        reseller_id: { type: integer }
        company: { type: string, nullable: true }
        request_id: { type: string }
        server_time:
          type: string
          format: date-time
          description: Useful when a signature or an expiry is being rejected and clock skew is suspected.
        auth:
          type: object
          description: Which credential arrived, and what it resolved to.
          required: [method]
          properties:
            method:
              type: string
              enum: [token, session]
              description: "`token` is an `nsin_live_` machine credential; `session` is a dashboard JWT."
            token_id: { type: integer }
            key_id: { type: string, description: The public half of the credential. }
            name: { type: string }
            expires_at: { type: string, format: date-time, nullable: true }
        rate_limit:
          type: object
          required: [limit, window_seconds]
          properties:
            limit:
              type: integer
              description: Requests allowed per window, per credential.
              examples: [300]
            window_seconds: { type: integer, examples: [60] }

    SupportContact:
      type: object
      description: |
        Rendered to the reseller's clients in place of the ticket UI. Free-form on
        purpose — NSIN does not model the reseller's support process.
      properties:
        title: { type: string, examples: ["پشتیبانی پارسا هاست"] }
        phone: { type: string }
        email: { type: string, format: email }
        telegram: { type: string }
        url: { type: string, format: uri }
        body:
          type: string
          description: Free text / markdown shown under the contact details.

    DomainTraffic:
      type: object
      description: One domain's traffic in the requested period.
      required: [domain_id, domain_name, total_requests, total_bandwidth, unique_visitors]
      properties:
        domain_id: { type: integer }
        domain_name: { type: string, examples: ["shop.example.ir"] }
        total_requests: { type: integer }
        total_bandwidth: { type: integer, description: Bytes served to visitors. }
        unique_visitors:
          type: integer
          description: |
            Distinct (client IP, JA4 TLS fingerprint) pairs. Pairing the two
            separates people sharing one CGNAT address by device stack; on plain
            HTTP it degrades to counting IPs.
        error_rate:
          type: number
          description: Percentage of this domain's responses with status >= 400.
        latest_event:
          type: string
          format: date-time
          nullable: true
          description: |
            When this domain last served a request in the window, or `null` if
            it served none. `null` is a real answer — "silent" — and a caller
            must not render it as a date it invented.
        series:
          type: array
          description: |
            Requests per bucket, oldest first, for a sparkline. Present but
            empty for a domain with no traffic; a caller must draw nothing
            rather than a flat line along zero, which reads as measured silence
            when it may be an unanswered question.
          items: { $ref: '#/components/schemas/RequestPoint' }

    RequestPoint:
      type: object
      description: One time bucket of request counts.
      required: [timestamp, count]
      properties:
        timestamp:
          type: string
          description: Bucket start, server-local, `YYYY-MM-DD HH:MM:SS` or ISO-8601.
        count: { type: integer }

    RequestSeries:
      type: object
      description: A request-count series, oldest bucket first.
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/RequestPoint' }

    BandwidthSeries:
      type: object
      description: A bytes-in/bytes-out series, oldest bucket first.
      required: [data]
      properties:
        data:
          type: array
          items:
            type: object
            required: [timestamp, bytes_in, bytes_out]
            properties:
              timestamp: { type: string }
              bytes_in: { type: integer, description: Bytes received from visitors. }
              bytes_out: { type: integer, description: Bytes served to visitors. }

    StatusCodeBreakdown:
      type: object
      description: Response statuses and how often each occurred, busiest first.
      required: [data]
      properties:
        data:
          type: array
          items:
            type: object
            required: [status_code, count]
            properties:
              status_code: { type: integer, examples: [200, 404, 502] }
              count: { type: integer }

    CacheBreakdown:
      type: object
      description: |
        Cache outcomes for the period. `hit_rate` is hits / (hits + misses) —
        bypassed requests are excluded from the denominator because a bypass is
        a decision not to cache, not a cache failure, and counting it as one
        makes a correctly configured API look broken.
      properties:
        hits: { type: integer }
        misses: { type: integer }
        bypass: { type: integer }
        hit_rate: { type: number, description: Percentage, 0 when nothing was looked up. }
        bypass_reasons:
          type: object
          additionalProperties: { type: integer }
          description: Why requests bypassed the cache, by reason. Absent when none did.

    TenancyTraffic:
      type: object
      description: The roll-up across every domain of every client.
      required: [domains, total_requests, total_bandwidth, unique_visitors, error_rate]
      properties:
        domains: { type: integer, description: How many domains the figures cover. }
        total_requests: { type: integer }
        total_bandwidth: { type: integer }
        unique_visitors:
          type: integer
          description: |
            Computed over the whole tenancy, NOT summed from the rows: one
            person browsing three of your sites is one visitor.
        error_rate: { type: number, description: Percentage of responses with status >= 400. }

    DomainAnalyticsSummary:
      type: object
      description: |
        One domain's volume and latency. Response-time figures exclude
        WebSocket requests — a WS duration spans the whole upgraded connection,
        so long-lived sockets would swamp the average.
      properties:
        total_requests: { type: integer }
        total_bandwidth: { type: integer }
        unique_visitors: { type: integer }
        error_rate: { type: number }
        avg_response_time: { type: number, description: Seconds. }
        p90: { type: number }
        p95: { type: number }
        p99: { type: number }

    LogPage:
      type: object
      description: |
        A page of raw request rows, newest first. Each row is one request served
        at the edge and carries the end user's IP, user agent and headers.
      required: [data, total, limit, offset]
      properties:
        data:
          type: array
          items: { type: object, additionalProperties: true }
        total: { type: integer, description: Matching rows before pagination. }
        limit: { type: integer }
        offset: { type: integer }

    WafLogPage:
      type: object
      description: |
        A page of WAF audit rows, newest first, each with the rule ids and
        messages that matched and the data that triggered them.
      required: [data, total, limit, offset]
      properties:
        data:
          type: array
          items: { type: object, additionalProperties: true }
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }

    # -------------------------------------------------------------------------
    # Uptime
    #
    # These mirror the Go types in outage/http.go rather than the customer
    # spec's, because the customer spec is wrong about the incident list: it
    # documents a bare array, and the handler has always returned
    # `{"incidents": [...]}`. A generated client built from the wrong one gets a
    # type error at runtime and nothing catches it until a panel is white.
    # -------------------------------------------------------------------------

    OutageIncidentList:
      type: object
      required: [incidents]
      properties:
        incidents:
          type: array
          items: { $ref: '#/components/schemas/OutageIncident' }

    OutageIncident:
      type: object
      required: [id, hostname, state, ongoing, started_at, duration_seconds, peak_err_pct, sample_reqs]
      properties:
        id: { type: integer }
        hostname: { type: string, description: The subdomain that was down, not the zone. }
        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. Absent is not "resolved at the
            zero time" — render the two differently.
        duration_seconds:
          type: integer
          description: |
            Measured to `resolved_at`, or to now while the incident is still
            open, so an ongoing outage's duration grows as you watch it.
        peak_err_pct: { type: number, description: Highest origin-error percentage reached. }
        sample_reqs: { type: integer, description: Requests observed over the incident. }

    UptimeLive:
      type: object
      required: [window_min, hosts]
      properties:
        window_min:
          type: integer
          description: |
            Length of the trailing window, in minutes — this domain's own
            detection window, not a fixed value.
        hosts:
          type: array
          description: |
            Never null. An empty array means no host served anything in the
            window, which is a real answer and must not render as an error.
          items: { $ref: '#/components/schemas/UptimeLiveStatus' }

    UptimeLiveStatus:
      type: object
      required: [hostname, requests, errors, error_pct, down, incident]
      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. Computed exactly as
            the detector computes it, so this cannot disagree with `incident`
            except during the minutes between a breach and the incident opening.
        incident: { type: boolean, description: An incident is currently open for this host. }

    UptimeSettings:
      type: object
      required: [enabled, threshold_pct, window_min, min_requests, min_active_min, recover_min, bounds]
      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. It is what stops one
            failed request on a near-idle staging host paging somebody at 3am.
        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' }

    UptimeSettingsBounds:
      type: object
      description: |
        Valid range for each configurable field, served with the values so a
        form can bound its own inputs instead of hard-coding limits that a
        deployment can change under it.
      required: [threshold_pct_min, threshold_pct_max, window_min_min, window_min_max, min_requests_min, recover_min_min, recover_min_max]
      properties:
        threshold_pct_min: { type: integer }
        threshold_pct_max: { type: integer }
        window_min_min: { type: integer }
        window_min_max: { type: integer, description: Deployment-wide cap; not a constant. }
        min_requests_min: { type: integer }
        recover_min_min: { type: integer }
        recover_min_max: { type: integer }

    UptimeSettingsUpdate:
      type: object
      description: |
        Every field is optional; omitted fields keep their current value. There
        is no way to express "reset to default" — send the default explicitly.
      properties:
        enabled: { type: boolean }
        threshold_pct: { type: integer }
        window_min: { type: integer }
        min_requests: { type: integer }
        min_active_min:
          type: integer
          description: |
            May not exceed `window_min`. If the two are changed together in a way
            that would break that, this one is lowered to match.
        recover_min: { type: integer }

    # -------------------------------------------------------------------------
    # Markdown for Agents
    # -------------------------------------------------------------------------

    MarkdownTesterResult:
      type: object
      required: [url, feature_enabled, html, markdown]
      properties:
        url: { type: string, description: The URL that was fetched, after host and path validation. }
        feature_enabled:
          type: boolean
          description: |
            The domain's `markdown_for_agents` setting at test time. With it off,
            `markdown.converted` will be false — that is the feature not being
            on, not the page failing to convert, and the two must not be reported
            as the same thing.
        html: { $ref: '#/components/schemas/MarkdownTesterFetch' }
        markdown: { $ref: '#/components/schemas/MarkdownTesterFetch' }

    MarkdownTesterFetch:
      type: object
      required: [status, content_type, content_length, body, truncated, converted]
      properties:
        status: { type: integer, description: The origin/edge status. 0 when the fetch itself failed — see `error`. }
        content_type: { type: string }
        content_length:
          type: integer
          description: Body length as returned, after the 256 KiB cap is applied.
        body: { type: string }
        truncated: { type: boolean, description: The body was longer than the cap and has been cut. }
        binary:
          type: boolean
          description: The body was not valid UTF-8, so `body` is omitted rather than mangled.
        cache_status: { type: string, description: The edge's `Nsn-Cache-Status`, when it sent one. }
        markdown_tokens: { type: integer, description: From `X-Markdown-Tokens`. Absent when the edge did not count. }
        original_tokens: { type: integer, description: From `X-Original-Tokens`. Absent when the edge did not count. }
        converted: { type: boolean, description: The response came back as `text/markdown`. }
        error:
          type: string
          description: |
            Why the fetch failed, when it did. Present with a zero `status` means
            nothing was reached at all — do not render that as a 0 response code.
