openapi: 3.0.3
info:
  title: Atlas Cloud Platform API
  version: "1.0"
  description: |
    Cloud infrastructure platform API for managing virtual machines, bare metal servers,
    block storage, object storage, networking, DNS, and more.

    ## Authentication

    All authenticated endpoints accept a `Bearer` token in the `Authorization` header.
    Two token types are supported:

    - **JWT access tokens** — obtained via `/auth/login`, short-lived
    - **API keys** — created via `/api-keys`, long-lived, prefixed with `atlas_key_`

    Example: `Authorization: Bearer atlas_key_abc123...`

    ## Rate Limiting

    - **API key requests:** 1000 per hour
    - **Unauthenticated / JWT requests:** 100 per hour per IP

    Rate-limited responses return `429 Too Many Requests` with a `Retry-After` header.

    ## Errors

    All errors return JSON with an `error` field:
    ```json
    {"error": "description of the problem"}
    ```

servers:
  - url: /v1
    description: API v1

security:
  - bearerAuth: []

tags:
  - name: Auth
    description: Authentication, account management, 2FA
  - name: API Keys
    description: API key management for programmatic access
  - name: Sessions
    description: Signed-in sessions (devices) and signing them out
  - name: Compute
    description: Virtual machines (VPS)
  - name: Baremetal
    description: Dedicated bare metal servers
  - name: Billing
    description: Wallet, invoices, payments, promo codes
  - name: Storage
    description: Block storage volumes
  - name: Object Storage
    description: S3-compatible object storage buckets
  - name: Networking
    description: Floating IPs and private networks (VPC)
  - name: DNS
    description: DNS zones and records
  - name: Domains
    description: Domain registration, transfer, renewal, contacts, and discovery
  - name: Snapshots
    description: VM snapshots and backups
  - name: SSH Keys
    description: SSH key management
  - name: Support
    description: Support tickets
  - name: Teams
    description: Team and member management
  - name: Firewall
    description: Per-VM firewall rules
  - name: Webhooks
    description: Customer webhook endpoints and delivery logs
  - name: Integrations
    description: Slack and Discord webhook integrations
  - name: Marketplace
    description: App templates and one-click deployments
  - name: Account
    description: Addons, announcements, feedback, notifications, docs, GDPR
  - name: Orders
    description: Orders, quotes, credit notes, cancellations, checkout
  - name: Colocation
    description: |
      Your own hardware in our rack. Read-only views observed from our switch
      (port status, traffic, usage against the commit) and two priced requests,
      remote hands and delivery registration, which reach our team only after you
      accept the price. Reverse DNS for your addresses is `PUT /ips/{id}/rdns`.
      There are no site visits: installation is ours, everything after it is
      remote hands, and the hardware returns by courier.

paths:
  # ── Auth ──────────────────────────────────────────────
  /auth/register:
    post:
      tags: [Auth]
      summary: Register a new account
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, minLength: 8 }
                name: { type: string }
                company: { type: string }
                recaptcha_token: { type: string }
      responses:
        "201":
          description: Account created
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer: { $ref: "#/components/schemas/Customer" }
                  access_token: { type: string }
                  refresh_token: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409": { description: Email already registered, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /auth/login:
    post:
      tags: [Auth]
      summary: Log in
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
                recaptcha_token: { type: string }
      responses:
        "200":
          description: |
            Login successful, or 2FA required. When the account has 2FA the response
            carries `totp_required`, `customer_id` and `challenge` instead of tokens;
            send `challenge` to `/auth/login/2fa` with the code. It is valid for five
            minutes and for five code attempts, and is used up by the sign-in.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tokens: { $ref: "#/components/schemas/TokenPair" }
                  totp_required: { type: boolean }
                  customer_id: { type: string, format: uuid }
                  challenge: { type: string, description: "Present with totp_required; required by /auth/login/2fa" }
                  password_expired: { type: boolean, description: "The password has passed the brand's maximum age and must be changed" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/login/2fa:
    post:
      tags: [Auth]
      summary: Complete 2FA login
      description: |
        Send either `code` (from the authenticator app) or `recovery_code` (one of
        the account's recovery codes, which is used up). A sign-in with a recovery
        code is emailed to the account holder and returns how many codes remain.

        `challenge` is the value `/auth/login` returned with `totp_required`, for
        the same customer on the same brand. A missing, expired or used-up
        challenge, or five wrong codes against one, is refused with 401 and
        `sign_in_again: true`: sign in with the password again to get a new one.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_id, challenge]
              properties:
                customer_id: { type: string, format: uuid }
                challenge: { type: string, description: "From the /auth/login response that asked for 2FA" }
                code: { type: string, description: Authenticator code }
                recovery_code: { type: string, description: "Recovery code, XXXX-XXXX; used when code is empty" }
      responses:
        "200":
          description: Tokens issued
          content:
            application/json:
              schema:
                type: object
                required: [tokens]
                properties:
                  tokens: { $ref: "#/components/schemas/TokenPair" }
                  recovery_codes_remaining: { type: integer, description: Present after a recovery-code sign-in }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Wrong code, or the challenge is no longer valid (`sign_in_again` is true)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      sign_in_again: { type: boolean, description: "Go back to the password step; this challenge will not be accepted again" }

  /auth/refresh:
    post:
      tags: [Auth]
      summary: Refresh access token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        "200":
          description: New tokens
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenPair" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/logout:
    post:
      tags: [Auth]
      summary: Log out (revoke refresh token)
      security: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                refresh_token: { type: string }
      responses:
        "200": { description: Logged out }

  /auth/forgot-password:
    post:
      tags: [Auth]
      summary: Request password reset email
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200": { description: If the email exists a reset link is sent }

  /auth/reset-password:
    post:
      tags: [Auth]
      summary: Reset password with token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token: { type: string }
                password: { type: string, minLength: 8 }
      responses:
        "200": { description: Password reset }
        "400": { $ref: "#/components/responses/BadRequest" }

  /auth/me:
    get:
      tags: [Auth]
      summary: Get current user profile
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Customer" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/language-prompt:
    post:
      tags: [Auth]
      summary: Answer the one-time language prompt
      description: >-
        Shown when GET /auth/me carries `suggested_language`: the customer's country implies a
        language we carry that differs from their current one, and they have not answered before.
        Send the suggested value to switch to it, or an empty string to keep the current language.
        Either answer stops the prompt for good. Any other value is refused; the profile endpoint
        is the place to choose a language freely.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [language]
              properties:
                language: { type: string, description: "The suggested locale to accept, or \"\" to decline", example: fr }
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  language: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/phone:
    put:
      tags: [Auth]
      summary: Update phone number
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                phone: { type: string, description: "E.164 format, e.g. +1234567890" }
      responses:
        "204": { description: Updated }
        "400": { $ref: "#/components/responses/BadRequest" }

  /auth/activity:
    get:
      tags: [Auth]
      summary: Get activity log
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AuditEntry" }

  /auth/2fa/setup:
    post:
      tags: [Auth]
      summary: Generate TOTP secret for 2FA setup
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret: { type: string }
                  url: { type: string, description: otpauth URI }

  /auth/2fa/verify:
    post:
      tags: [Auth]
      summary: Verify TOTP code to enable 2FA
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        "200": { description: 2FA enabled }
        "400": { $ref: "#/components/responses/BadRequest" }

  /auth/2fa:
    delete:
      tags: [Auth]
      summary: Disable 2FA
      responses:
        "204": { description: 2FA disabled }

  # ── API Keys ──────────────────────────────────────────
  /api-keys:
    get:
      tags: [API Keys]
      summary: List API keys
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/APIKey" }
    post:
      tags: [API Keys]
      summary: Create API key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "201":
          description: Key created (plaintext key returned only once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  key: { type: string, description: "Full API key — store securely, shown only once" }
                  name: { type: string }
                  created_at: { type: string, format: date-time }

  /api-keys/{id}:
    delete:
      tags: [API Keys]
      summary: Delete API key
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Deleted }
    put:
      tags: [API Keys]
      summary: Update an API key's allowed IPs
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                allowed_ips: { type: array, items: { type: string }, description: "CIDR ranges or IPs" }
      responses:
        "200": { description: updated, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: invalid body / bad CIDR, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: api key not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ── Sessions ──────────────────────────────────────────
  /sessions:
    get:
      tags: [Sessions]
      summary: List signed-in sessions
      description: >-
        Returns the customer's live sessions, most recently active first. To have the
        caller's own session marked with `is_current: true`, send the lower-case hex
        SHA-256 of the refresh token this client holds in the `X-Session-Token-Hash`
        header. Without the header no session is marked.
      parameters:
        - name: X-Session-Token-Hash
          in: header
          required: false
          description: Lower-case hex SHA-256 of this client's refresh token; marks the matching session as current.
          schema: { type: string, pattern: "^[0-9a-f]{64}$" }
      responses:
        "200":
          description: Live sessions
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Session" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    delete:
      tags: [Sessions]
      summary: Sign out all other sessions
      description: >-
        Ends every session of the customer except the one named, and revokes their
        refresh tokens. The session to keep must be named (its `id` from the list) in
        the `X-Current-Session-ID` header or in the body as `except_id`, and must be one
        of the customer's own live sessions; otherwise nothing is ended and the answer
        is 400.
      parameters:
        - name: X-Current-Session-ID
          in: header
          required: false
          description: The `id` of the session to keep. Used when the body does not carry `except_id`.
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                except_id: { type: string, description: "The id of the session to keep (alternative to the header)" }
      responses:
        "200":
          description: Other sessions ended
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [revoked] }
                  revoked: { type: integer, format: int64, description: "How many sessions were ended" }
        "400":
          description: The session to keep was not named, or is not one of the customer's live sessions
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /sessions/{id}:
    delete:
      tags: [Sessions]
      summary: Sign out one session
      description: >-
        Ends the session and revokes its refresh token, so that device can no longer renew
        its sign-in. The caller's own session can be named too; the dashboard offers this
        for other sessions only.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Session ended
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [revoked] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Compute ───────────────────────────────────────────
  /plans:
    get:
      tags: [Compute]
      summary: List VM plans
      security: []
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/VMPlan" }

  /templates:
    get:
      tags: [Compute]
      summary: List OS templates
      security: []
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/OSTemplate" }

  /locations:
    get:
      tags: [Compute]
      summary: List available locations
      security: []
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Location" }

  # ── Marketplace ──────────────────────────────────────────────
  /templates/apps:
    get:
      tags: [Marketplace]
      summary: List app templates
      description: Returns all active app templates, optionally filtered by category.
      security: []
      parameters:
        - name: category
          in: query
          schema:
            type: string
            enum: [web, database, devops, analytics, cms, ecommerce]
          description: Filter by category
      responses:
        "200":
          description: List of app templates
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AppTemplate" }

  /templates/apps/categories:
    get:
      tags: [Marketplace]
      summary: List template categories
      security: []
      responses:
        "200":
          description: List of available categories
          content:
            application/json:
              schema:
                type: array
                items: { type: string }
                example: [web, database, devops, analytics, cms, ecommerce]

  /templates/apps/{id}:
    get:
      tags: [Marketplace]
      summary: Get app template details
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: Template ID or slug
      responses:
        "200":
          description: App template details
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppTemplate" }
        "404":
          description: Template not found

  /templates/apps/deploy:
    post:
      tags: [Marketplace]
      summary: Deploy an app template
      description: |
        Creates a new VM with the selected app template pre-installed.
        The template's cloud-init script is rendered with provided variables
        and passed to the VM during provisioning.

        You do not choose an operating system. The application deploys on the
        image named by its `os_template_slug`, which is the version it was
        proven on. `template_id` is optional and, when the application is
        pinned, it is ignored in favour of that image.

        Check `deploy_ready` before ordering. An application that is not ready
        is refused with 503 before anything is created or charged.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeployAppRequest" }
      responses:
        "201":
          description: Deployment initiated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeployAppResponse" }
        "400":
          description: Invalid request (missing fields or invalid variables)
        "404":
          description: Template not found
        "503":
          description: >-
            This application is not available for one-click deployment yet.
            Refused before anything is created, so nothing was charged. Other
            applications may still be available; readiness is per application.

  /templates/apps/deployments:
    get:
      tags: [Marketplace]
      summary: List my app deployments
      description: Returns all app template deployments for the authenticated customer.
      responses:
        "200":
          description: List of deployments
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AppDeployment" }

  /templates/apps/deployments/{id}:
    get:
      tags: [Marketplace]
      summary: Get deployment status
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Deployment details
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppDeployment" }
        "404":
          description: Deployment not found

  /vms:
    get:
      tags: [Compute]
      summary: List VMs
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/VM" }
    post:
      tags: [Compute]
      summary: Create VM (deprecated for ordering)
      deprecated: true
      description: >-
        STILL SUPPORTED, and existing API automation keeps working unchanged: this
        creates a VM and, for monthly billing, a pending invoice you then pay from
        Billing. It is deprecated only as the ORDERING path, because order features
        now live on the unified checkout endpoint and are NOT available here: promo
        codes, settling the order from account balance at order time, and the exact
        shortfall response when the balance is short. The Atlas dashboard moved to
        the unified endpoint; this route is kept for API clients. No removal date is
        set and none will be set without notice to API users.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateVMRequest" }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VM" }
        "402":
          description: >-
            Payment required. Returned when an HOURLY order cannot be covered by the
            customer's wallet balance, or when the fraud prepayment gate declines the
            order. Nothing is created in either case, so this is safe to retry once the
            underlying condition is fixed.

            Branch on `code`: `insufficient_balance` carries `need_cents`, `have_cents`
            and `shortfall_cents` so you can tell the customer exactly how much to add.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, description: Human-readable reason }
                  code:
                    type: string
                    enum: [insufficient_balance]
                    description: Stable machine-readable reason. Absent on older responses.
                  currency: { type: string, example: NGN }
                  need_cents: { type: integer, format: int64, description: minor units required for one hour }
                  have_cents: { type: integer, format: int64, description: minor units currently available }
                  shortfall_cents: { type: integer, format: int64, description: top up at least this much }
                  order_created: { type: boolean, description: always false on this branch }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /vms/{id}:
    get:
      tags: [Compute]
      summary: Get VM
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VM" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Compute]
      summary: Update VM name or hostname
      description: >-
        Renames a server or sets its hostname. Name is a free human-readable label and
        is not validated; hostname is optional and must be a valid DNS name when set.
        A field that is omitted is left unchanged; a hostname sent as an empty string
        is cleared. This updates the record held by the platform, including reverse-DNS
        eligibility, and does not change the hostname configured inside a running guest.
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 255
                  example: My web server
                hostname:
                  type: string
                  example: web01.example.com
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VM" }
        "400": { description: Invalid hostname, empty name, or nothing to update }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Compute]
      summary: Destroy VM
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Destroyed }

  /vms/{id}/actions/start:
    post:
      tags: [Compute]
      summary: Start VM
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200": { description: Started }

  /vms/{id}/actions/stop:
    post:
      tags: [Compute]
      summary: Stop VM
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200": { description: Stopped }

  /vms/{id}/actions/reboot:
    post:
      tags: [Compute]
      summary: Reboot VM
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200": { description: Rebooted }

  /vms/{id}/resize:
    post:
      tags: [Compute]
      summary: Resize VM to a new plan
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan_id]
              properties:
                plan_id: { type: string, format: uuid }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VM" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /vms/{id}/console:
    get:
      tags: [Compute]
      summary: Get VNC console access URL
      description: >-
        One-time URL for the graphical console. The server must be running.
        The URL is short-lived and single-use; request a new one per session.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  console_url: { type: string }

  /vms/{id}/serial-console:
    get:
      tags: [Compute]
      summary: Get serial console access URL
      description: >-
        One-time URL for the SERIAL console, the rescue path when SSH and the
        graphical console are both unusable. Access rules are identical to the
        graphical console: the server must be running and belong to you.


        Requires a serial device on the guest. Servers created from current
        templates have one. Older servers, including those migrated from a
        previous platform, do not, and return 409 until the device is added,
        which takes effect at the server's next restart.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  console_url: { type: string }
        "409":
          description: >-
            The server has no serial device yet. It becomes available after the
            server's next restart.

  /vms/{id}/metrics:
    get:
      tags: [Compute]
      summary: Get VM metrics
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object

  /vms/{id}/firewall:
    get:
      tags: [Firewall]
      summary: Get firewall rules
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/FirewallRule" }
    put:
      tags: [Firewall]
      summary: Replace firewall rules
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                rules:
                  type: array
                  items: { $ref: "#/components/schemas/FirewallRuleInput" }
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/FirewallRule" }

  # ── Snapshots & Backups ───────────────────────────────
  /vms/{id}/snapshots:
    get:
      tags: [Snapshots]
      summary: List snapshots
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Snapshot" }
    post:
      tags: [Snapshots]
      summary: Create snapshot
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Snapshot" }

  /vms/{id}/snapshots/{sid}/restore:
    post:
      tags: [Snapshots]
      summary: Restore snapshot
      parameters:
        - $ref: "#/components/parameters/id"
        - name: sid
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: Restore initiated }

  /vms/{id}/snapshots/{sid}:
    delete:
      tags: [Snapshots]
      summary: Delete snapshot
      parameters:
        - $ref: "#/components/parameters/id"
        - name: sid
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Deleted }

  /vms/{id}/backups:
    get:
      tags: [Snapshots]
      summary: List backups
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Backup" }

  /vms/{id}/backups/schedule:
    get:
      tags: [Snapshots]
      summary: Get backup schedule
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BackupSchedule" }
    put:
      tags: [Snapshots]
      summary: Set backup schedule
      description: >-
        Automated backups run daily. Any other frequency is refused with 400 and nothing is
        changed. A server that does not exist or is not yours answers 404, a server without a
        backup subscription answers 409 (enable backups first), and a fault on our side answers 500.
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [frequency, retention]
              properties:
                frequency: { type: string, enum: [daily] }
                retention: { type: integer, minimum: 1, maximum: 3, description: "Copies kept. A value outside 1 to 3 is stored as 3, the number the add-on keeps." }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BackupSchedule" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: The server has no backup subscription, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Schedule could not be saved, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      tags: [Snapshots]
      summary: Delete backup schedule
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Deleted }

  # ── Baremetal ─────────────────────────────────────────
  /baremetal/skus:
    get:
      tags: [Baremetal]
      summary: List available SKUs
      description: >-
        Each SKU carries its pricing, its free machines per location (`stock`) and how it
        is sold per location (`fulfilment`): from stock, or built to order with a lead time
        in business days (Monday to Friday) and the date an order placed today is promised
        by. A SKU with a build_to_order entry is listed even with no machine in stock.
      security: []
      parameters:
        - in: query
          name: location
          schema: { type: string }
          description: Optional; only SKUs sold at this location (a free machine or a build_to_order entry).
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/BaremetalSKU" }

  /baremetal/servers:
    get:
      tags: [Baremetal]
      summary: List servers
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/BaremetalServer" }
    post:
      tags: [Baremetal]
      summary: Order a server
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateServerRequest" }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BaremetalServer" }
        "400": { description: "invalid_field: a bad field, a malformed ssh key, an hourly billing mode (bare metal is billed per cycle), notes over 1,000 characters, or prepay_renewal on a built-to-order configuration; missing_required_field: neither ssh_keys nor one_time_password" }
        "409": { description: "product_unavailable: the SKU is not sold at the location (no free machine and no build-to-order entry); nothing was charged" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /baremetal/servers/{id}:
    get:
      tags: [Baremetal]
      summary: Get server
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BaremetalServer" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Baremetal]
      summary: Update server name or hostname
      description: >-
        Renames a server or sets its hostname. Name is a free human-readable label and
        is not validated; hostname is optional and must be a valid DNS name when set.
        A field that is omitted is left unchanged; a hostname sent as an empty string
        is cleared. This updates the record held by the platform and does not change
        the hostname configured inside the running operating system.
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 255
                  example: Database node
                hostname:
                  type: string
                  example: db01.example.com
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BaremetalServer" }
        "400": { description: Invalid hostname, empty name, or nothing to update }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Baremetal]
      summary: Cancel a built-to-order order before handover (otherwise refused)
      description: |
        Withdraws a BUILT-TO-ORDER order that has not been handed over yet. What
        happens depends on where the order is:

        - `awaiting_confirmation`: nothing has been charged. The order is cancelled.
        - `pending_payment` (confirmed, invoice unpaid): the invoice and the order are
          cancelled; there is nothing to pay.
        - `awaiting_hardware` before the hardware has been ordered for you
          (`hardware_ordered_at` absent): the order is cancelled and what you paid is
          returned IN FULL to your account balance, once, however often the request is
          sent.
        - `awaiting_hardware` with `hardware_ordered_at` set, or `provisioning`: REFUSED
          with 409. The hardware has been bought for you and the twelve-month minimum
          term applies from handover; the message says so.

        You are emailed the outcome. A cancelled order no longer exists, so a repeated
        request answers 404.

        Every other bare-metal server (stock, or built to order and handed over) is
        cancelled at the end of the paid period through
        `POST /baremetal/servers/{id}/cancel` with `cancel_type: end_of_period`; this
        route answers 409 for it and changes nothing, as it always has.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: The built-to-order order was cancelled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [cancelled] }
                  stage:
                    type: string
                    enum: [unconfirmed, unpaid, paid]
                    description: >-
                      unconfirmed: nothing had been charged. unpaid: the invoice was
                      cancelled. paid: the amount was refunded to the account balance.
                  refunded:
                    type: string
                    description: "stage paid only: the amount returned, e.g. \"USD 229.00\"."
                  refund_pending:
                    type: boolean
                    description: >-
                      stage paid only, rare: the order is cancelled but the refund could not
                      be written at that moment. It is retried automatically and you are
                      emailed when it is made.
        "404": { description: No such server on this account }
        "409":
          description: >-
            Refused, with the reason: the hardware has already been ordered (the term
            applies), the order is being confirmed or paid at that moment (reload and try
            again), or the server is not a built-to-order order before handover (cancel it
            at the end of the paid period instead).

  /baremetal/servers/{id}/actions/{action}:
    post:
      tags: [Baremetal]
      summary: Perform server action
      parameters:
        - $ref: "#/components/parameters/id"
        - name: action
          in: path
          required: true
          schema:
            type: string
            enum: [power-on, power-off, reboot, power-cycle, rescue, exit-rescue, reinstall, install-ssh-keys]
      requestBody:
        description: >-
          `install-ssh-keys` appends the SSH keys saved in your account to root on the installed
          system through a short rescue boot (about ten minutes, the server shows `rescuing`);
          nothing else on the disk changes. For `reinstall` only: The server is released with a disk erase and installed again on
          the same addresses and blocks, with your dashboard SSH keys of the moment and the same
          password behaviour as the first install. `confirm_name` must equal the server's name.
          `os` is an id from `GET /baremetal/os`; omitted keeps the current one.
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                os: { type: string, example: ubuntu/noble }
                confirm_name: { type: string, example: web-01 }
      responses:
        "200": { description: Action performed }
        "409": { description: The server is not in a state that allows the action, or the confirmation name did not match }

  /baremetal/os:
    get:
      tags: [Baremetal]
      summary: List operating systems available for an order or a reinstall (no key needed)
      description: The images our provisioning system carries for bare metal. Ids are MAAS series names.
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string, example: ubuntu/noble }
                    name: { type: string, example: Ubuntu 24.04 LTS }

  /baremetal/backup-tiers:
    get:
      tags: [Baremetal]
      summary: List backup add-on sizes and prices
      description: The backup sizes offered in your currency for a bare-metal server, monthly.
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    size_gb: { type: integer, example: 500 }
                    currency: { type: string, example: USD }
                    price_monthly: { type: number, example: 15 }

  /baremetal/servers/{id}/backup:
    get:
      tags: [Baremetal]
      summary: Backup add-on status
      description: >-
        The server's backup add-on, if any: the S3 bucket name and endpoint, the access key,
        the bytes used and the quota. The secret key is on the credentials route.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  addon: { type: object, nullable: true }
                  bucket: { type: string }
                  endpoint: { type: string }
                  access_key: { type: string }
                  size_bytes: { type: integer }
                  quota_gb: { type: integer }
    post:
      tags: [Baremetal]
      summary: Add backup storage to a server
      description: >-
        Creates a private S3 bucket for this server with a hard quota at the size chosen, with
        its own credentials. Billed monthly from the server's next renewal at the price shown
        by `GET /baremetal/backup-tiers`. One add-on per server. The customer runs their own
        backup tool (restic is documented); nothing is installed on the server.
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [size_gb]
              properties:
                size_gb: { type: integer, example: 500 }
      responses:
        "201": { description: Created; the body is the status object }
        "409": { description: Already has an add-on, or the server is not deployed }
        "503": { description: The backup store is not available }
    delete:
      tags: [Baremetal]
      summary: Remove the backup add-on
      description: Deletes the bucket and everything in it. Billing stops at the next renewal.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Removed }

  /baremetal/servers/{id}/backup/credentials:
    get:
      tags: [Baremetal]
      summary: Backup bucket credentials
      description: The S3 endpoint, bucket, access key and secret key for the server's backup bucket. Audited.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint: { type: string }
                  bucket: { type: string }
                  access_key: { type: string }
                  secret_key: { type: string }

  /baremetal/servers/{id}/console:
    get:
      tags: [Baremetal]
      summary: Open a console session
      description: >-
        Opens a short-lived console session on the server's controller. When the controller
        cannot bring its console up, the answer is 200 with access_ready false and a message,
        not an error: nothing the caller sent was wrong.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_ready: { type: boolean }
                  message: { type: string, description: Present when access_ready is false }
                  session_id: { type: string }
                  expires_at: { type: string, format: date-time }
                  console_url: { type: string }
                  token: { type: string }
                  password: { type: string, description: One-time console password; dies with the session }

  /baremetal/servers/{id}/initial-password:
    get:
      tags: [Baremetal]
      summary: Reveal the one-time root password
      description: >-
        Returns the root password set at installation, for servers whose order asked for
        one. The operating system forces a change at first login. The password is shown
        only here, never in email or tickets, and stays available until you remove it or
        the server is reinstalled. 404 with code `not_found` when none is held.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean, example: true }
                  username: { type: string, example: root }
                  password: { type: string }
        "404":
          description: No one-time password is held for this server, or the server does not exist.
    delete:
      tags: [Baremetal]
      summary: Remove the stored one-time root password
      description: >-
        Deletes our copy of the password. The password on the server itself is
        unchanged; change it at first login. Idempotent.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204":
          description: Removed (or nothing was held).

  /baremetal/servers/{id}/rescue-password:
    get:
      tags: [Baremetal]
      summary: Reveal the rescue root password
      description: >-
        While the server is in rescue mode (state `rescue`), returns the root password of
        the temporary rescue system. Your dashboard SSH keys are installed there as well,
        and the server answers on its usual public addresses. The password is shown only
        here, never in email, and stops working when rescue ends: on `exit-rescue`, or
        automatically 24 hours after entry. 404 with code `not_found` when the server is
        not in rescue.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean, example: true }
                  username: { type: string, example: root }
                  password: { type: string }
        "404":
          description: The server is not in rescue mode, or does not exist.

  /notifications:
    get:
      tags: [Account]
      summary: Emails we sent you, with delivery status
      description: >-
        The notification centre. Every email Atlas sent to the signed-in customer's own
        address, newest first, with the mail provider's delivery verdict where one has
        arrived (delivered, bounced, dropped, deferred). Copies sent to our staff and
        internal alerts raised on your behalf are not included. Up to 500 with `limit`.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  notifications:
                    type: array
                    items: { $ref: "#/components/schemas/CustomerNotification" }
  /baremetal/servers/{id}/bandwidth:
    get:
      tags: [Baremetal]
      summary: Get bandwidth samples and 95th percentile
      parameters:
        - $ref: "#/components/parameters/id"
        - name: from
          in: query
          schema: { type: string, format: date }
        - name: to
          in: query
          schema: { type: string, format: date }
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  server_id: { type: string }
                  from: { type: string, format: date-time }
                  to: { type: string, format: date-time }
                  pct95_mbps: { type: number }
                  bw_billing:
                    type: string
                    description: How bandwidth is billed for this server, "included" (flat) or "95th" (95th percentile against included_mbps).
                  included_mbps: { type: integer, description: Included 95th-percentile rate from the plan; 0 when flat }
                  price_per_mbps: { type: number, description: Overage price per Mbps above included_mbps, in the account currency }
                  samples:
                    type: array
                    items: { $ref: "#/components/schemas/BandwidthSample" }

  # ── Colocation (ADR-018) ──────────────────────────────
  /colo:
    get:
      tags: [Colocation]
      summary: List your colocation services
      responses:
        "200":
          description: Your colocation assignments, newest first
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ColoAssignment" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /colo/{id}:
    get:
      tags: [Colocation]
      summary: Get one colocation service, with its IPv4 addresses
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: The assignment and its addresses
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ColoAssignmentDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /colo/{id}/traffic:
    get:
      tags: [Colocation]
      summary: Traffic on your port, observed from our switch
      description: |
        Averages per bucket: five minutes for `day`, thirty minutes for `week`, two hours
        for `month`. Also returns the current calendar month's 95th percentile so far
        and your commit; transit is billed only above the commit.
      parameters:
        - $ref: "#/components/parameters/id"
        - name: range
          in: query
          schema: { type: string, enum: [day, week, month], default: day }
      responses:
        "200":
          description: The traffic graph and the month's usage against the commit
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ColoTraffic" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /colo/{id}/requests:
    get:
      tags: [Colocation]
      summary: Your remote-hands and delivery requests, with the published prices
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Requests and prices
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ColoRequests" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Colocation]
      summary: Ask for remote hands or register a delivery
      description: |
        Records the request with its price and dispatches nothing. Remote hands are billed
        per hour at the published rate with a one-hour minimum; the request states the
        hours. Accept the price with `POST /colo/{id}/requests/{rid}/accept`.
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ColoRequestInput" }
      responses:
        "201":
          description: The request, awaiting your acceptance of its price
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ColoServiceRequest" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The service does not accept this request in its current state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /colo/{id}/requests/{rid}/accept:
    post:
      tags: [Colocation]
      summary: Accept the price of a request, which then reaches our team
      description: |
        Send the price you were shown. A request whose price differs is refused (409),
        so nobody accepts a figure they did not see.
      parameters:
        - $ref: "#/components/parameters/id"
        - name: rid
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accepted_price_cents]
              properties:
                accepted_price_cents: { type: integer, format: int64 }
      responses:
        "200":
          description: The accepted request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ColoServiceRequest" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The price changed or the request is no longer awaiting acceptance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /colo/{id}/requests/{rid}/withdraw:
    post:
      tags: [Colocation]
      summary: Withdraw a request you have not accepted
      parameters:
        - $ref: "#/components/parameters/id"
        - name: rid
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The withdrawn request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ColoServiceRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Only a request awaiting your acceptance can be withdrawn
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ── Billing ───────────────────────────────────────────
  /billing/balance:
    get:
      tags: [Billing]
      summary: Get wallet balance
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Wallet" }

  # /billing/top-up removed (B-437): wallet top-ups now go through
  # POST /billing/checkout (Stripe Checkout Session). Documented below.

  /billing/invoices:
    get:
      tags: [Billing]
      summary: List invoices
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Invoice" }

  /billing/invoices/{id}:
    get:
      tags: [Billing]
      summary: Get invoice
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }

  /billing/invoices/{id}/payments:
    get:
      tags: [Billing]
      summary: Payments made against this invoice, including one that arrived short
      description: >
        Lists the customer's own payments for the invoice. A pending crypto payment that the
        gateway received short carries `underpaid`, what was asked and what arrived, and the
        `checkout_url` to send the difference to the same gateway invoice. Gateway references
        and internal notes are never included.
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: Payments for the invoice
          content:
            application/json:
              schema:
                type: object
                properties:
                  payments:
                    type: array
                    items:
                      $ref: "#/components/schemas/CustomerPayment"
        "404":
          description: Invoice not found
  /billing/invoices/{id}/pdf:
    get:
      tags: [Billing]
      summary: Download invoice PDF
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          description: PDF file
          content:
            application/pdf:
              schema:
                type: string
                format: binary

  /billing/usage:
    get:
      tags: [Billing]
      summary: Get current usage
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/UsageRecord" }

  /billing/transactions:
    get:
      tags: [Billing]
      summary: List transactions
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Transaction" }

  /billing/promo:
    post:
      tags: [Billing]
      summary: Redeem promo code
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transaction" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /billing/checkout:
    post:
      tags: [Billing]
      summary: Create payment checkout session
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, gateway, success_url, cancel_url]
              properties:
                amount: { type: number }
                gateway: { type: string, enum: [stripe, paypal, crypto, pawapay, flutterwave, bachs] }
                success_url: { type: string, format: uri }
                cancel_url: { type: string, format: uri }
                invoice_id: { type: string, description: "when set, pay this invoice; amount locks to the invoice total" }
                pay_from_country:
                  type: string
                  description: "pawaPay only (ISO alpha-2): the ACTIVE corridor to pay from, decoupled from account country; amount stays fixed (Atlas converts). Empty = account country."
                bachs_method:
                  type: string
                  description: "Bachs only: the method to pay with, one of the account country's entries in bachs_methods on the brand config (for example NGN_BANK_TRANSFER). Empty = the country's default. Any other value is refused with 400 before a payment is created. Amount stays fixed: Atlas converts to the method's currency."
                settle_invoice_id:
                  type: string
                  format: uuid
                  description: >-
                    Top-ups only (omit alongside invoice_id). Marks this top-up as funding one
                    specific pending invoice: when the top-up completes and the balance covers
                    that invoice, it is settled automatically and provisioning starts, so a
                    part-funded order needs no second action. Only the named invoice is ever
                    settled - a top-up without this never touches a pending invoice. Must be
                    your own unpaid invoice (404/403 otherwise). Note the top-up minimum still
                    applies, so a shortfall smaller than the minimum means topping up more than
                    the difference; the excess stays as balance.
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  checkout_url: { type: string, format: uri }
                  payment_id: { type: string }

  # ── Block Storage ─────────────────────────────────────
  /storage/volumes:
    get:
      tags: [Storage]
      summary: List volumes
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Volume" }
    post:
      tags: [Storage]
      summary: Create volume
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, location_id, size_gb]
              properties:
                name: { type: string }
                location_id: { type: string, format: uuid }
                size_gb: { type: integer, minimum: 10 }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Volume" }

  /storage/volumes/{id}:
    get:
      tags: [Storage]
      summary: Get volume
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Volume" }
    delete:
      tags: [Storage]
      summary: Delete volume
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Deleted }

  /storage/volumes/{id}/attach:
    post:
      tags: [Storage]
      summary: Attach volume to VM
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [vm_id]
              properties:
                vm_id: { type: string, format: uuid }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Volume" }

  /storage/volumes/{id}/detach:
    post:
      tags: [Storage]
      summary: Detach volume from VM
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Volume" }

  /storage/volumes/{id}/resize:
    post:
      tags: [Storage]
      summary: Resize volume
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [size_gb]
              properties:
                size_gb: { type: integer }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Volume" }

  # ── Object Storage ────────────────────────────────────
  /storage/buckets:
    get:
      tags: [Object Storage]
      summary: List buckets
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Bucket" }
    post:
      tags: [Object Storage]
      summary: Create bucket
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, location_id]
              properties:
                name:
                  type: string
                  description: 3 to 63 lower-case letters, digits and hyphens; no dots. Names the platform reserves are refused.
                location_id:
                  type: string
                  description: Must be the store's location shown in /brand/config object_storage.location_id (eu-lis-1).
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Bucket" }
        "409": { description: The bucket name is already in use }
        "422": { description: Invalid or reserved name, location not offered, or a quota reached }

  /storage/buckets/{id}:
    get:
      tags: [Object Storage]
      summary: Get bucket
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Bucket" }
    delete:
      tags: [Object Storage]
      summary: Delete bucket
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Deleted }
        "409": { description: The bucket is managed by an add-on; cancel the add-on instead }

  /storage/buckets/{id}/credentials:
    get:
      tags: [Object Storage]
      summary: Get S3 credentials
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_key: { type: string }
                  secret_key: { type: string }
                  endpoint: { type: string }
                  region:
                    type: string
                    description: SigV4 signing region (eu-lis-1). The store refuses requests signed for any other region.

  # ── Floating IPs ──────────────────────────────────────
  /floating-ips:
    get:
      tags: [Networking]
      summary: List floating IPs
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/FloatingIP" }
    post:
      tags: [Networking]
      summary: Reserve floating IP
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [location_id]
              properties:
                location_id: { type: string, format: uuid }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FloatingIP" }

  /floating-ips/{id}:
    get:
      tags: [Networking]
      summary: Get floating IP
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FloatingIP" }
    delete:
      tags: [Networking]
      summary: Release floating IP
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Released }

  /floating-ips/{id}/assign:
    post:
      tags: [Networking]
      summary: Assign floating IP to resource
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resource_id, resource_type]
              properties:
                resource_id: { type: string, format: uuid }
                resource_type: { type: string, enum: [vm, baremetal] }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FloatingIP" }

  /floating-ips/{id}/unassign:
    post:
      tags: [Networking]
      summary: Unassign floating IP
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FloatingIP" }

  /floating-ips/{id}/rdns:
    put:
      tags: [Networking]
      summary: Update reverse DNS
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rdns]
              properties:
                rdns: { type: string }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FloatingIP" }

  # ── Private Networks (VPC) ────────────────────────────
  /networking/private-networks:
    get:
      tags: [Networking]
      summary: List private networks
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/PrivateNetwork" }
    post:
      tags: [Networking]
      summary: Create private network
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, location_id, subnet]
              properties:
                name: { type: string }
                location_id: { type: string, format: uuid }
                subnet: { type: string, description: "CIDR notation, e.g. 10.0.0.0/24" }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PrivateNetwork" }

  /networking/private-networks/{id}:
    get:
      tags: [Networking]
      summary: Get private network
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PrivateNetwork" }
    delete:
      tags: [Networking]
      summary: Delete private network
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Deleted }

  /networking/private-networks/{id}/members:
    post:
      tags: [Networking]
      summary: Add member to private network
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [vm_id, private_ip]
              properties:
                vm_id: { type: string, format: uuid }
                private_ip: { type: string, format: ipv4 }
      responses:
        "201":
          description: >
            Member added. The private NIC is attached at the hypervisor. On a Linux
            guest that is running, the address is also configured inside the guest.
            Otherwise (a Windows server, a network appliance, a Linux guest that is
            not running, or an in-guest step that failed) it is not, and
            `configure_in_guest` gives the reason, the adapter's MAC address and the
            address to set by hand (no gateway).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NetworkMemberAdded"
        "200":
          description: >
            The server was already attached to this network with this address, so
            nothing was done at the hypervisor. The body is the existing member; when
            the address was never configured inside the guest, `configure_in_guest`
            repeats what the first response said. Retrying is safe: it never attaches
            a second interface.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NetworkMemberAdded"
        "409":
          description: >
            The server is already attached to this network with a DIFFERENT private
            address. Remove it from the network first, then add it with the new one.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /networking/private-networks/{id}/members/{vmID}:
    delete:
      tags: [Networking]
      summary: Remove member from private network
      parameters:
        - $ref: "#/components/parameters/id"
        - name: vmID
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Member removed }

  # ── DNS ───────────────────────────────────────────────
  /dns/zones:
    get:
      tags: [DNS]
      summary: List DNS zones
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/DNSZone" }
    post:
      tags: [DNS]
      summary: Create DNS zone
      description: >
        Zone creation is currently disabled brand-wide and returns 403. Listing,
        reading and deleting existing zones are unaffected. Contact support if you
        need a zone added.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DNSZone" }
        "403":
          description: Zone creation is disabled for this brand, or the account is not active and verified.

  /dns/zones/{id}:
    get:
      tags: [DNS]
      summary: Get DNS zone
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DNSZone" }
    delete:
      tags: [DNS]
      summary: Delete DNS zone
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Deleted }

  /dns/zones/{id}/records:
    get:
      tags: [DNS]
      summary: List DNS records
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/DNSRecord" }
    post:
      tags: [DNS]
      summary: Create DNS record
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateDNSRecordRequest" }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DNSRecord" }

  /dns/zones/{id}/records/{rid}:
    put:
      tags: [DNS]
      summary: Update DNS record
      parameters:
        - $ref: "#/components/parameters/id"
        - name: rid
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateDNSRecordRequest" }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DNSRecord" }
    delete:
      tags: [DNS]
      summary: Delete DNS record
      parameters:
        - $ref: "#/components/parameters/id"
        - name: rid
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Deleted }

  # ── SSH Keys ──────────────────────────────────────────
  /ssh-keys:
    get:
      tags: [SSH Keys]
      summary: List SSH keys
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/SSHKey" }
    post:
      tags: [SSH Keys]
      summary: Add SSH key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, public_key]
              properties:
                name: { type: string }
                public_key: { type: string }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SSHKey" }

  /ssh-keys/{id}:
    delete:
      tags: [SSH Keys]
      summary: Delete SSH key
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Deleted }

  # ── Support ───────────────────────────────────────────
  /support/tickets:
    get:
      tags: [Support]
      summary: List tickets
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Ticket" }
    post:
      tags: [Support]
      summary: Create ticket
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, body]
              properties:
                subject: { type: string }
                body: { type: string }
                priority: { type: string, enum: [low, medium, high], default: medium }
                department: { type: string }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ticket" }

  /chat/sessions:
    post:
      tags: [Support]
      summary: Open a live chat session
      description: |
        Opens a live chat session for the signed-in customer and sets the resume
        cookie `atlas_chat` (HttpOnly, Secure, SameSite=Lax, Path=/chat/) on the
        dashboard host; the chat widget then connects to `/chat/v1/ws` with it.
        Not available to an API key or in a read-only session. Answers 404 when
        the live chat is not available for the brand.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                locale: { type: string, description: "The customer's language, e.g. en, fr, pt" }
                handoff_token: { type: string, description: "One-time token from the storefront chat; links that conversation to this session. Invalid or used tokens open a fresh session." }
      responses:
        "201":
          content:
            application/json:
              schema:
                type: object
                required: [session_id, handed_off]
                properties:
                  session_id: { type: string, format: uuid }
                  handed_off: { type: boolean, description: "True when handoff_token linked the storefront conversation." }
        "403": { description: an API key or a read-only session, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: the live chat is not available for this brand, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: too many sessions in the last hour, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /support/tickets/{id}:
    get:
      tags: [Support]
      summary: Get ticket with replies
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TicketWithReplies" }
    put:
      tags: [Support]
      summary: Update ticket status
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { type: string, enum: [open, closed] }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ticket" }

  /support/tickets/{id}/replies:
    post:
      tags: [Support]
      summary: Add reply to ticket
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Reply" }

  # ── Teams ─────────────────────────────────────────────
  /teams:
    get:
      tags: [Teams]
      summary: List teams
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Team" }
    post:
      tags: [Teams]
      summary: Create team
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Team" }

  /teams/{id}:
    get:
      tags: [Teams]
      summary: Get team with members
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TeamWithMembers" }
    put:
      tags: [Teams]
      summary: Update team
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Team" }
    delete:
      tags: [Teams]
      summary: Delete team
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Deleted }

  /teams/{id}/members:
    post:
      tags: [Teams]
      summary: Add team member
      parameters:
        - $ref: "#/components/parameters/id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [admin, member, viewer] }
      responses:
        "201": { description: Member added }

  /teams/{id}/members/{mid}:
    put:
      tags: [Teams]
      summary: Update member role
      parameters:
        - $ref: "#/components/parameters/id"
        - name: mid
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { type: string, enum: [admin, member, viewer] }
      responses:
        "200": { description: Updated }
    delete:
      tags: [Teams]
      summary: Remove team member
      parameters:
        - $ref: "#/components/parameters/id"
        - name: mid
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Removed }

  # ── Webhooks ──────────────────────────────────────────────
  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/WebhookEndpoint" }
    post:
      tags: [Webhooks]
      summary: Create webhook endpoint
      description: "The signing secret is returned only on creation. Each delivery is a POST of the event name and its data (target_type, target_id, occurred_at), signed with an HMAC-SHA256 of the body in the X-Webhook-Signature header."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                  description: "Event filter (empty = all events). Events are the actions in your activity log, named like vm.create, vm.destroy and dns.record_create."
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" }

  /webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Delete webhook endpoint
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Deleted }

  /webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: List recent deliveries
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/WebhookDelivery" }

  /integrations:
    get:
      tags: [Integrations]
      summary: List Slack/Discord integrations
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Integration" }
    post:
      tags: [Integrations]
      summary: Create Slack or Discord integration
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, webhook_url]
              properties:
                type: { type: string, enum: [slack, discord] }
                webhook_url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                  description: "Event filter (empty = all events). Events are the actions in your activity log, named like vm.create, vm.destroy and dns.record_create."
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Integration" }

  /integrations/{id}:
    delete:
      tags: [Integrations]
      summary: Delete integration
      parameters:
        - $ref: "#/components/parameters/id"
      responses:
        "204": { description: Deleted }

# ── Components ────────────────────────────────────────────
  # ---- Domains (B-472) ---------------------------------------------------
  /domains/availability:
    get:
      tags: [Domains]
      summary: Check domain availability
      security: []
      parameters:
        - { name: name, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: availability, content: { application/json: { schema: { $ref: "#/components/schemas/Availability" } } } }
        "400": { description: missing/invalid name, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: feature disabled for brand }
        "502": { description: registrar check failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/pricing:
    get:
      tags: [Domains]
      summary: Retail price for a domain operation
      security: []
      parameters:
        - { name: name, in: query, required: true, schema: { type: string } }
        - name: op
          in: query
          schema: { type: string, enum: [register, create, renew, transfer, restore], default: register }
        - { name: years, in: query, schema: { type: integer, default: 1, minimum: 1, maximum: 10 } }
      responses:
        "200": { description: price, content: { application/json: { schema: { $ref: "#/components/schemas/PriceResult" } } } }
        "400": { description: missing/invalid params, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: feature disabled for brand }
        "422": { description: minimum-term commitment required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: pricing failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/price-summary:
    get:
      tags: [Domains]
      summary: Full per-name pricing picture
      security: []
      parameters:
        - { name: name, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: price summary, content: { application/json: { schema: { $ref: "#/components/schemas/PriceSummary" } } } }
        "400": { description: missing/invalid name, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: feature disabled for brand }
        "502": { description: pricing failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/suggestions:
    get:
      tags: [Domains]
      summary: Searched name plus ranked alternatives
      security: []
      parameters:
        - { name: name, in: query, required: true, description: full domain or bare keyword, schema: { type: string } }
      responses:
        "200": { description: suggestions, content: { application/json: { schema: { $ref: "#/components/schemas/SuggestionsResponse" } } } }
        "400": { description: missing/invalid name, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: feature disabled for brand }
        "502": { description: suggestion load failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/suggestions/prices:
    get:
      tags: [Domains]
      summary: Live prices for up to 25 suggestion names
      security: []
      parameters:
        - { name: names, in: query, required: true, description: comma-separated domain names (max 25), schema: { type: string } }
      responses:
        "200":
          description: prices
          content:
            application/json:
              schema:
                type: object
                properties:
                  prices: { type: array, items: { $ref: "#/components/schemas/Suggestion" } }
        "502": { description: pricing load failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/suggestions/aftermarket:
    get:
      tags: [Domains]
      summary: Aftermarket (Sedo/Afternic) listings for names
      security: []
      parameters:
        - { name: name, in: query, required: true, schema: { type: string } }
        - { name: names, in: query, description: comma-separated domain names (max 25), schema: { type: string } }
      responses:
        "200":
          description: aftermarket listings
          content:
            application/json:
              schema:
                type: object
                properties:
                  aftermarket: { type: array, items: { $ref: "#/components/schemas/Suggestion" } }
        "400": { description: invalid name, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: aftermarket load failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/suggestions/ai:
    get:
      tags: [Domains]
      summary: AI-generated brandable suggestions (budget-capped)
      parameters:
        - { name: name, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: suggestions (empty when the engine is unconfigured)
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestions: { type: array, items: { $ref: "#/components/schemas/Suggestion" } }
        "400": { description: missing/invalid name, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: feature disabled for brand }
        "502": { description: generation failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/transfer/check:
    get:
      tags: [Domains]
      summary: Check transfer eligibility for a domain
      security: []
      parameters:
        - { name: name, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: eligibility
          content:
            application/json:
              schema: { type: object, description: registrar transfer-eligibility result }
        "400": { description: missing/invalid name, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/select:
    post:
      tags: [Domains]
      summary: Log a suggestion selection (funnel signal, best-effort)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "204": { description: recorded }
        "400": { description: invalid body }

  /domains:
    get:
      tags: [Domains]
      summary: List the customer's registered domains
      responses:
        "200": { description: domains, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Domain" } } } } }
        "500": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    post:
      tags: [Domains]
      summary: Register a domain (creates an order + invoice; pay to provision)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/RegisterDomainRequest" } } }
      responses:
        "201": { description: order created, content: { application/json: { schema: { $ref: "#/components/schemas/DomainOrderResponse" } } } }
        "400": { description: invalid body/params, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: price changed since quote / aftermarket unavailable, content: { application/json: { schema: { $ref: "#/components/schemas/DomainPriceChanged" } } } }
        "422": { description: minimum-term commitment required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: pricing/invoice failure, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/batch:
    post:
      tags: [Domains]
      summary: Register up to 20 domains on one order (shared contact)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/BatchRegisterRequest" } } }
      responses:
        "201": { description: order created, content: { application/json: { schema: { $ref: "#/components/schemas/DomainBatchResponse" } } } }
        "400": { description: invalid count/name/years/duplicate/contact, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: a line's price changed since quote (carries name), content: { application/json: { schema: { $ref: "#/components/schemas/DomainPriceChanged" } } } }
        "422": { description: minimum-term commitment required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: pricing/invoice failure, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/transfer:
    post:
      tags: [Domains]
      summary: Transfer a domain in (creates an order + invoice)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/TransferDomainRequest" } } }
      responses:
        "201": { description: transfer order created, content: { application/json: { schema: { $ref: "#/components/schemas/DomainOrderResponse" } } } }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: price changed since quote, content: { application/json: { schema: { $ref: "#/components/schemas/DomainPriceChanged" } } } }
        "422": { description: minimum-term commitment required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: pricing/invoice failure, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/contacts:
    get:
      tags: [Domains]
      summary: List saved registrant contacts
      responses:
        "200": { description: contacts, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/ContactBookEntry" } } } } }
        "500": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    post:
      tags: [Domains]
      summary: Save a reusable registrant contact
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/ContactBookRequest" } } }
      responses:
        "201": { description: saved, content: { application/json: { schema: { $ref: "#/components/schemas/ContactBookEntry" } } } }
        "400": { description: invalid body/email/country, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: save failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/contacts/{id}:
    delete:
      tags: [Domains]
      summary: Delete a saved contact
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "204": { description: deleted }
        "500": { description: delete failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Domains]
      summary: Get a registered domain
      responses:
        "200": { description: domain, content: { application/json: { schema: { $ref: "#/components/schemas/Domain" } } } }
        "404": { description: not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/{id}/renew:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Domains]
      summary: Renew a domain (creates an order + invoice)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/RenewDomainRequest" } } }
      responses:
        "201": { description: renewal order created, content: { application/json: { schema: { $ref: "#/components/schemas/DomainOrderResponse" } } } }
        "400": { description: invalid body/years, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: not active yet / price changed, content: { application/json: { schema: { $ref: "#/components/schemas/DomainPriceChanged" } } } }
        "502": { description: pricing/invoice failure, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/{id}/restore:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Domains]
      summary: Restore a domain in the redemption period (RGP)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/RestoreDomainRequest" } } }
      responses:
        "201": { description: restore order created, content: { application/json: { schema: { $ref: "#/components/schemas/DomainOrderResponse" } } } }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: not in redemption / price changed, content: { application/json: { schema: { $ref: "#/components/schemas/DomainPriceChanged" } } } }
        "501": { description: restore not supported by the registrar, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: pricing/invoice failure, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/{id}/transfer/cancel:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Domains]
      summary: Cancel a pending inbound transfer
      responses:
        "200": { description: updated domain, content: { application/json: { schema: { $ref: "#/components/schemas/Domain" } } } }
        "502": { description: cancel failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/{id}/lock:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Domains]
      summary: Enable the registrar transfer lock
      responses:
        "200": { description: updated domain, content: { application/json: { schema: { $ref: "#/components/schemas/Domain" } } } }
        "409": { description: domain not active yet, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: registrar failure, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/{id}/unlock:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Domains]
      summary: Disable the registrar transfer lock
      responses:
        "200": { description: updated domain, content: { application/json: { schema: { $ref: "#/components/schemas/Domain" } } } }
        "409": { description: domain not active yet, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: registrar failure, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/{id}/whois-privacy:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Domains]
      summary: Toggle WHOIS privacy
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [enabled], properties: { enabled: { type: boolean } } }
      responses:
        "200": { description: updated domain, content: { application/json: { schema: { $ref: "#/components/schemas/Domain" } } } }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "501": { description: WHOIS privacy not available for this TLD, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: registrar failure, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/{id}/auto-renew:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Domains]
      summary: Toggle auto-renew
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [enabled], properties: { enabled: { type: boolean } } }
      responses:
        "200": { description: updated domain, content: { application/json: { schema: { $ref: "#/components/schemas/Domain" } } } }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: update failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/{id}/nameservers:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      tags: [Domains]
      summary: Set nameservers (or switch to Atlas NS)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/SetNameserversRequest" } } }
      responses:
        "200": { description: updated domain, content: { application/json: { schema: { $ref: "#/components/schemas/Domain" } } } }
        "400": { description: invalid body / fewer than 2 nameservers, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: registrar failure, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/{id}/auth-code:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Domains]
      summary: Retrieve the transfer auth (EPP) code
      responses:
        "200":
          description: auth code
          content:
            application/json:
              schema: { type: object, properties: { auth_code: { type: string } } }
        "502": { description: registrar failure, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /domains/{id}/sync:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Domains]
      summary: Refresh domain state from the registrar
      responses:
        "200": { description: updated domain, content: { application/json: { schema: { $ref: "#/components/schemas/Domain" } } } }
        "502": { description: registrar failure, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }


  # ---- VM lifecycle tail (B-472) ----------------------------------------
  /vms/{id}/actions/suspend:
    post:
      tags: [Compute]
      summary: Suspend VM
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: suspended, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: vm not provisioned yet, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/actions/resume:
    post:
      tags: [Compute]
      summary: Resume VM
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: resumed, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: vm not provisioned yet, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/reinstall:
    post:
      tags: [Compute]
      summary: Reinstall VM from an OS template (destructive)
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [template_id], properties: { template_id: { type: string } } }
      responses:
        "200": { description: reinstalled, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: template_id required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: reinstall failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/reinstall-templates:
    get:
      tags: [Compute]
      summary: List the images this server may be reinstalled with
      description: The OS templates on sale, plus the image the server already runs when that image is off sale. /templates stays the list for new orders.
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: templates, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/OSTemplate" } } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/password:
    post:
      tags: [Compute]
      summary: Reset the VM root/user password
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                username: { type: string, description: "optional; defaults to the template family's admin account: Administrator on Windows, root on Linux. Network appliances (VyOS, RouterOS) are refused with 422; their credential is set at build time" }
                password: { type: string, description: "optional; auto-generated when omitted" }
      responses:
        "200":
          description: reset
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  username: { type: string }
                  password: { type: string, description: "returned only when auto-generated" }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422":
          description: >-
            Reset failed, or (when the body carries `password_set: true`) the password WAS set but
            password login over SSH could not be enabled: the body then also carries `username` and,
            when auto-generated, `password`, which work from the console. Network appliances are
            refused here.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Error" }
                  - type: object
                    properties:
                      password_set: { type: boolean }
                      username: { type: string }
                      password: { type: string, description: "present only when auto-generated" }

  /vms/{id}/exec:
    post:
      tags: [Compute]
      summary: Run a command via the guest agent
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [command], properties: { command: { type: string, maxLength: 1000 } } }
      responses:
        "200": { description: result, content: { application/json: { schema: { $ref: "#/components/schemas/VMExecResult" } } } }
        "400": { description: command required / too long, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: execution failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/tags:
    put:
      tags: [Compute]
      summary: Replace the VM's tags
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, properties: { tags: { type: array, items: { type: string } } } }
      responses:
        "204": { description: tags set }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: set failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/guest-info:
    get:
      tags: [Compute]
      summary: Guest-agent OS, network, and filesystem info
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: guest info, content: { application/json: { schema: { $ref: "#/components/schemas/VMGuestInfo" } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: guest info unavailable, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/initial-password:
    get:
      tags: [Compute]
      summary: The initial provisioning password (if still available)
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200":
          description: password status
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean }
                  username: { type: string }
                  password: { type: string }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: retrieval failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/bandwidth:
    get:
      tags: [Compute]
      summary: Raw bandwidth samples
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: samples, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/VMBandwidthSample" } } } } }
        "422": { description: bandwidth unavailable, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/bandwidth-usage:
    get:
      tags: [Compute]
      summary: Aggregated bandwidth usage over a period (max 90 days)
      parameters:
        - { $ref: "#/components/parameters/id" }
        - { name: from, in: query, schema: { type: string, format: date }, description: "default: first of month" }
        - { name: to, in: query, schema: { type: string, format: date }, description: "default: now" }
      responses:
        "200": { description: usage, content: { application/json: { schema: { $ref: "#/components/schemas/VMBandwidthUsage" } } } }
        "422": { description: usage unavailable, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/resize/preview:
    get:
      tags: [Compute]
      summary: Preview a plan change (price + feasibility)
      parameters:
        - { $ref: "#/components/parameters/id" }
        - { name: plan_id, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: preview, content: { application/json: { schema: { $ref: "#/components/schemas/ResizePreview" } } } }
        "400": { description: plan_id required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: preview failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Edge firewall for VMs (C-082): rules on the location's edge router ----
  /vms/{id}/edge-firewall:
    get:
      tags: [Firewall]
      summary: List edge firewall rules for a VM
      description: Rules enforced at the network edge of the VM's location, distinct from the per-guest rule set at /vms/{id}/firewall.
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: rules, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/EdgeFirewallRule" } } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    post:
      tags: [Firewall]
      summary: Create an edge firewall rule for a VM (max 50 per customer)
      description: Refused with 409 location_unsupported where the VM's location has no edge router configured; nothing is saved in that case.
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/EdgeFirewallRuleInput" } } }
      responses:
        "201": { description: created, content: { application/json: { schema: { $ref: "#/components/schemas/EdgeFirewallRule" } } } }
        "400": { description: invalid body / max rules reached, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: location_unsupported, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: the edge refused the rule; nothing saved, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: create failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/edge-firewall/{rule_id}:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: rule_id, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      tags: [Firewall]
      summary: Enable/disable a VM edge firewall rule
      description: Disable removes the rule from the edge router before the flag is written; enable re-applies it. 409 location_unsupported when the edge is not reachable, 502 if it refuses; the flag is unchanged in both cases.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [enabled], properties: { enabled: { type: boolean } } }
      responses:
        "200": { description: updated, content: { application/json: { schema: { $ref: "#/components/schemas/EdgeFirewallRule" } } } }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: vm or rule not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: update failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      tags: [Firewall]
      summary: Delete a VM edge firewall rule
      description: Removed from the edge router first; 409 or 502 with the rule kept if the edge cannot confirm.
      responses:
        "204": { description: deleted }
        "409": { description: location_unsupported, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: the edge did not confirm; rule kept, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: vm or rule not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: delete failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/isos:
    get:
      tags: [Compute]
      summary: List mountable ISO images
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: ISOs, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/ISOInfo" } } } } }
        "422": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    post:
      tags: [Compute]
      summary: Upload your own disk image
      description: >-
        Uploads an .iso or .img to this server's location. The image is private
        to your account: other customers on the same host can neither see nor
        mount it. Maximum 1 GB per image and 3 images per location. Send as
        multipart/form-data in a field named "file".


        The image must be BOOTABLE MEDIA: an ISO 9660 volume carrying an El
        Torito boot record. A raw disk image never has one, whatever its
        extension says, so cloud images and appliance .img files are refused
        here rather than mounted and found unbootable later.


        The 1 GB ceiling counts the whole multipart body, not the file alone,
        and is enforced at our edge before the request reaches the API, so
        exceeding it returns 413 with a plain-text body rather than the JSON
        error envelope used elsewhere. One upload at a time per account; a
        second concurrent upload answers 429. Allow up to 30 minutes for a
        large image on a slow link; the request stays open until the image is
        staged, checked and copied to the host.
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "201": { description: uploaded, content: { application/json: { schema: { type: object, properties: { volid: { type: string } } } } } }
        "400": { description: "wrong file type, not bootable media, too many images already, or this location is low on storage for uploaded images", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "413": { description: "the request body exceeded the size limit; refused at the edge, so the body is plain text and not the JSON error envelope" }
    delete:
      tags: [Compute]
      summary: Delete an image you uploaded
      description: >-
        Removes one of your own uploaded images. Images provided by AFRICLOUD
        are shared and cannot be deleted.
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [volid], properties: { volid: { type: string } } }
      responses:
        "204": { description: deleted }
        "400": { description: not yours to delete, currently mounted, or volid missing, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/mount-iso:
    post:
      tags: [Compute]
      summary: Mount an ISO
      description: >-
        Attaches the image as a virtual CD AND makes it the first boot device,
        so the server boots from it on its next start. Stop and start the server
        for the change to take effect. Unmounting restores the previous boot
        order. Only one image can be mounted at a time.


        Pass the volid exactly as returned by GET /vms/{id}/isos. Images
        belonging to other customers are not mountable and are not listed.
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [iso_path], properties: { iso_path: { type: string } } }
      responses:
        "200": { description: mounted, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: iso_path required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: mount failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/unmount-iso:
    post:
      tags: [Compute]
      summary: Unmount the ISO
      description: >-
        Detaches the mounted image and restores the boot order that was in place
        before it was mounted, so the server returns to booting its own disk.
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: unmounted, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "422": { description: unmount failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/alerts:
    get:
      tags: [Compute]
      summary: List metric alerts for a VM
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: alerts, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/VMAlert" } } } } }
        "500": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    post:
      tags: [Compute]
      summary: Create a metric alert
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/CreateVMAlertRequest" } } }
      responses:
        "201": { description: created, content: { application/json: { schema: { $ref: "#/components/schemas/VMAlert" } } } }
        "400": { description: invalid metric/operator/body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: create failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/alerts/{aid}:
    delete:
      tags: [Compute]
      summary: Delete a metric alert
      parameters:
        - { name: aid, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "204": { description: deleted }
        "500": { description: delete failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/power-schedules:
    get:
      tags: [Compute]
      summary: List power schedules
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: schedules, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/PowerSchedule" } } } } }
        "500": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    post:
      tags: [Compute]
      summary: Create a power schedule (cron start/stop/reboot)
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/CreatePowerScheduleRequest" } } }
      responses:
        "201": { description: created, content: { application/json: { schema: { $ref: "#/components/schemas/PowerSchedule" } } } }
        "400": { description: invalid action/cron/body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: create failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/power-schedules/{sid}:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: sid, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      tags: [Compute]
      summary: Enable/disable a power schedule
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [enabled], properties: { enabled: { type: boolean } } }
      responses:
        "200": { description: updated, content: { application/json: { schema: { type: object, properties: { enabled: { type: boolean } } } } } }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: update failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      tags: [Compute]
      summary: Delete a power schedule
      responses:
        "204": { description: deleted }
        "500": { description: delete failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/backups/pricing:
    get:
      tags: [Snapshots]
      summary: Backup add-on pricing for a VM
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200":
          description: pricing
          content:
            application/json:
              schema:
                type: object
                properties:
                  price_monthly: { type: integer, format: int64 }
                  active: { type: boolean }
                  frequency: { type: string }
                  retention: { type: integer }
        "400": { description: brand required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: not authorized, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: vm not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: pricing failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/backups/enable:
    post:
      tags: [Snapshots]
      summary: Enable scheduled backups (creates the schedule + add-on)
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "201": { description: enabled, content: { application/json: { schema: { $ref: "#/components/schemas/BackupSchedule" } } } }
        "400": { description: brand required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: enable failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      tags: [Snapshots]
      summary: Disable scheduled backups
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "204": { description: disabled }
        "422": { description: disable failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/backups/{bid}/restore:
    post:
      tags: [Snapshots]
      summary: Restore a backup (async, destructive)
      parameters:
        - { $ref: "#/components/parameters/id" }
        - { name: bid, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: restore queued }
        "422": { description: restore failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /vms/{id}/cancel:
    post:
      tags: [Compute]
      summary: Request cancellation of a VM
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        content: { application/json: { schema: { $ref: "#/components/schemas/CancelRequest" } } }
      responses:
        "200": { description: cancellation request, content: { application/json: { schema: { $ref: "#/components/schemas/CancellationRequest" } } } }
        "400": { description: invalid body / request failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Bare-metal tail (B-472) ------------------------------------------
  /baremetal/servers/{id}/cancel:
    post:
      tags: [Baremetal]
      summary: Request cancellation of a bare-metal server
      description: >-
        End of the paid period only. A built-to-order order that has not been handed over
        is refused here (400, with the reason): withdraw it with
        DELETE /baremetal/servers/{id} instead, which applies its refund or term rule.
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        content: { application/json: { schema: { $ref: "#/components/schemas/CancelRequest" } } }
      responses:
        "200": { description: cancellation request, content: { application/json: { schema: { $ref: "#/components/schemas/CancellationRequest" } } } }
        "400": { description: invalid body / request failed / a built-to-order order before handover, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /baremetal/servers/{id}/firewall:
    get:
      tags: [Baremetal]
      summary: List edge firewall rules
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: rules, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/EdgeFirewallRule" } } } } }
        "404": { description: server not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    post:
      tags: [Baremetal]
      summary: Create an edge firewall rule (max 50 per customer, counted across all servers)
      description: Applied to every address of the server in the source family before 201 is answered; refused with 409 location_unsupported where the location has no edge router, 409 resource_not_ready while the server has no address, and 502 if the edge refuses (nothing saved).
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/EdgeFirewallRuleInput" } } }
      responses:
        "201": { description: created, content: { application/json: { schema: { $ref: "#/components/schemas/EdgeFirewallRule" } } } }
        "400": { description: invalid body / max rules reached / no address in the source family, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: server not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: location_unsupported or resource_not_ready, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: the edge refused the rule; nothing saved, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: create failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /baremetal/servers/{id}/firewall/{rule_id}:
    parameters:
      - { $ref: "#/components/parameters/id" }
      - { name: rule_id, in: path, required: true, schema: { type: string, format: uuid } }
    patch:
      tags: [Baremetal]
      summary: Enable/disable an edge firewall rule
      description: Disable removes the rule from the edge router before the flag is written; enable re-applies it. 409 location_unsupported when the edge is not reachable, 502 if it refuses; the flag is unchanged in both cases.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [enabled], properties: { enabled: { type: boolean } } }
      responses:
        "200": { description: updated, content: { application/json: { schema: { $ref: "#/components/schemas/EdgeFirewallRule" } } } }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: server or rule not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: update failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      tags: [Baremetal]
      summary: Delete an edge firewall rule
      description: Removed from the edge router first; 409 or 502 with the rule kept if the edge cannot confirm.
      responses:
        "204": { description: deleted }
        "409": { description: location_unsupported, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: the edge did not confirm; rule kept, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: server or rule not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: delete failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }


  # ---- Billing: payment methods, subscriptions, invoice pay (B-472) ------
  /billing/payment-methods:
    get:
      tags: [Billing]
      summary: List saved payment methods
      responses:
        "200": { description: methods, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/PaymentMethod" } } } } }
        "500": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    post:
      tags: [Billing]
      summary: Begin adding a payment method (Stripe SetupIntent or PayPal)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                gateway: { type: string, enum: [stripe, paypal], default: stripe }
                success_url: { type: string }
                cancel_url: { type: string }
      responses:
        "200": { description: setup handles, content: { application/json: { schema: { $ref: "#/components/schemas/AddPaymentMethodResponse" } } } }
        "400": { description: invalid gateway / gateway unavailable, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: add failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /billing/payment-methods/{id}:
    delete:
      tags: [Billing]
      summary: Remove a saved payment method
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: removed, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: id required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: remove failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /billing/payment-methods/{id}/default:
    post:
      tags: [Billing]
      summary: Set a payment method as default
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: default set, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: id required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /billing/payment-methods/confirm-setup:
    post:
      tags: [Billing]
      summary: Confirm a Stripe SetupIntent and save the method
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [setup_intent_id], properties: { setup_intent_id: { type: string } } }
      responses:
        "200": { description: saved method, content: { application/json: { schema: { $ref: "#/components/schemas/PaymentMethod" } } } }
        "400": { description: invalid body / stripe unavailable, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: confirm failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /billing/payment-methods/paypal/confirm:
    post:
      tags: [Billing]
      summary: Confirm a PayPal billing agreement and save the method
      parameters:
        - { name: setup_token_id, in: query, schema: { type: string }, description: "fallback if not in body" }
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { setup_token_id: { type: string } } }
      responses:
        "200": { description: saved method, content: { application/json: { schema: { $ref: "#/components/schemas/PaymentMethod" } } } }
        "400": { description: token required / paypal unavailable, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: confirm failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /billing/payments/recent-completed:
    get:
      tags: [Billing]
      summary: The customer's most recent completed payment
      responses:
        "200":
          description: payment
          content:
            application/json:
              schema:
                type: object
                properties:
                  payment_id: { type: string }
                  amount: { type: number }
                  currency: { type: string }
        "404": { description: none, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /billing/payments/verify:
    post:
      tags: [Billing]
      summary: Verify a Stripe payment by gateway reference
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [gateway_ref], properties: { gateway_ref: { type: string, description: "Stripe pi_ or cs_ id" } } }
      responses:
        "200":
          description: verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, description: "completed | already_processed" }
                  payment_id: { type: string }
                  amount: { type: number }
                  currency: { type: string }
        "400": { description: bad ref / stripe not configured, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "402": { description: payment not yet completed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: no pending payment, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: stripe verify error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /billing/subscriptions:
    get:
      tags: [Billing]
      summary: List recurring subscriptions
      responses:
        "200": { description: subscriptions, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Subscription" } } } } }
        "500": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /billing/subscriptions/{id}/cancel:
    post:
      tags: [Billing]
      summary: Cancel a subscription
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { immediately: { type: boolean, default: false }, reason: { type: string } } }
      responses:
        "200": { description: cancelled, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: id required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: cancel failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /billing/invoices/{id}/pay:
    post:
      tags: [Billing]
      summary: Pay an invoice from wallet balance
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: paid invoice, content: { application/json: { schema: { $ref: "#/components/schemas/Invoice" } } } }
        "400": { description: insufficient balance / already paid, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /billing/invoices/{id}/verify-payment:
    post:
      tags: [Billing]
      summary: Verify and settle a pending gateway payment for an invoice
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: settled invoice, content: { application/json: { schema: { $ref: "#/components/schemas/Invoice" } } } }
        "400": { description: no pending payment / gateway not configured, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "402": { description: payment not yet completed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: invoice not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: gateway verify/capture error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Account / VAT (B-472) --------------------------------------------
  /account/vat:
    get:
      tags: [Auth]
      summary: The customer's VAT validation status
      responses:
        "200": { description: status, content: { application/json: { schema: { $ref: "#/components/schemas/VATStatus" } } } }
        "401": { description: not authenticated, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /account/vat/validate:
    post:
      tags: [Auth]
      summary: Validate a VAT number via VIES
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [vat_number], properties: { vat_number: { type: string } } }
      responses:
        "200": { description: result, content: { application/json: { schema: { $ref: "#/components/schemas/VATValidation" } } } }
        "400": { description: vat_number required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: VIES unavailable, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Auth / account (B-472) -------------------------------------------
  /auth/account:
    delete:
      tags: [Auth]
      summary: Schedule account deletion (30-day window)
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [password], properties: { password: { type: string } } }
      responses:
        "200":
          description: scheduled
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string }, message: { type: string } } }
        "400": { description: password required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { description: invalid password, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /auth/consents:
    get:
      tags: [Auth]
      summary: List GDPR consents
      responses:
        "200": { description: consents, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Consent" } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    put:
      tags: [Auth]
      summary: Update GDPR consents
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                type: object
                required: [consent_type, granted]
                properties:
                  consent_type: { type: string, enum: [terms, privacy, marketing, analytics] }
                  granted: { type: boolean }
      responses:
        "204": { description: updated }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /auth/recovery-codes/count:
    get:
      tags: [Auth]
      summary: Count of unused 2FA recovery codes
      responses:
        "200": { description: count, content: { application/json: { schema: { type: object, properties: { count: { type: integer }, available: { type: boolean } } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /auth/recovery-codes:
    post:
      tags: [Auth]
      summary: Regenerate 2FA recovery codes
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [password], properties: { password: { type: string } } }
      responses:
        "200":
          description: codes
          content:
            application/json:
              schema: { type: object, properties: { codes: { type: array, items: { type: string } }, message: { type: string } } }
        "400": { description: password required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { description: invalid password, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: recovery not available, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /auth/reset-password/validate:
    get:
      tags: [Auth]
      summary: Validate a password-reset token
      security: []
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: token info, content: { application/json: { schema: { $ref: "#/components/schemas/ResetTokenInfo" } } } }
        "400": { description: token required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /auth/resend-verification:
    post:
      tags: [Auth]
      summary: Resend the email-verification message
      responses:
        "200": { description: sent, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: already verified, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: cooldown, content: { application/json: { schema: { type: object, properties: { error: { type: string }, retry_after_sec: { type: integer } } } } } }

  /auth/verify-email:
    post:
      tags: [Auth]
      summary: Verify an email address by token
      security: []
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: verified, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: invalid/expired token, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /auth/account-info:
    put:
      tags: [Auth]
      summary: Update billing/account identity fields
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                account_type: { type: string, enum: [individual, company] }
                first_name: { type: string }
                last_name: { type: string }
                company_name: { type: string }
                vat_id: { type: string }
                tax_id: { type: string }
                company_reg_no: { type: string }
      responses:
        "204": { description: updated }
        "400": { description: invalid body / account_type, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /auth/password:
    put:
      tags: [Auth]
      summary: Change password (revokes all sessions)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: { type: string }
                new_password: { type: string }
      responses:
        "200": { description: changed, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: policy violation / reuse / missing fields, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { description: invalid current password, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /auth/profile:
    put:
      tags: [Auth]
      summary: Update profile (contact + locale)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                phone: { type: string }
                address1: { type: string }
                address2: { type: string }
                city: { type: string }
                state: { type: string }
                postcode: { type: string }
                country: { type: string }
                language: { type: string }
      responses:
        "204": { description: updated }
        "400": { description: invalid body / phone / too long, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # NOTE: POST /auth/ott (server-to-server SSO mint, RequireBrandAPIKey) is
  # INTERNAL and deliberately NOT documented here. See TestServedSpec_NoInternalGatedOps.
  /auth/ott/exchange:
    post:
      tags: [Auth]
      summary: Exchange a one-time token for a session
      description: |
        For an account with 2FA the token does not open a session: the response
        carries `totp_required`, `customer_id` and `challenge` instead, and the
        sign-in finishes at `/auth/login/2fa` with the code, as after `/auth/login`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [token], properties: { token: { type: string } } }
      responses:
        "200":
          description: session, or 2FA required
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  refresh_token: { type: string }
                  expires_in: { type: integer }
                  customer_id: { type: string }
                  totp_required: { type: boolean }
                  challenge: { type: string, description: "Present with totp_required; required by /auth/login/2fa" }
        "400": { description: token required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { description: invalid or expired token, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Verification (B-472) ---------------------------------------------
  /verification/status:
    get:
      tags: [Auth]
      summary: KYC/verification tier status
      responses:
        "200": { description: status, content: { application/json: { schema: { $ref: "#/components/schemas/VerificationStatus" } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /verification/activity:
    get:
      tags: [Auth]
      summary: Recent verification activity (last 50)
      responses:
        "200": { description: activity, content: { application/json: { schema: { type: object, properties: { activity: { type: array, items: { type: object } } } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /verification/credits:
    get:
      tags: [Auth]
      summary: Promotional credits and summary
      responses:
        "200":
          description: credits
          content:
            application/json:
              schema:
                type: object
                properties:
                  credits: { type: array, items: { type: object } }
                  summary: { $ref: "#/components/schemas/CreditSummary" }
        "503": { description: not available, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /verification/topup-cap:
    get:
      tags: [Auth]
      summary: Wallet top-up cap for the current period
      responses:
        "200": { description: cap, content: { application/json: { schema: { $ref: "#/components/schemas/TopupCap" } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /verification/phone/send:
    post:
      tags: [Auth]
      summary: Send a phone verification code
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [phone_number], properties: { phone_number: { type: string, description: "E.164" } } }
      responses:
        "200": { description: sent, content: { application/json: { schema: { type: object, properties: { message: { type: string } } } } } }
        "400": { description: invalid/placeholder phone, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: not available, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /verification/phone/verify:
    post:
      tags: [Auth]
      summary: Verify a phone code
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [phone_number, code], properties: { phone_number: { type: string }, code: { type: string } } }
      responses:
        "200": { description: verified, content: { application/json: { schema: { type: object, properties: { message: { type: string } } } } } }
        "400": { description: bad/expired code, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: not available, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }


  # ---- Addons (B-472) ---------------------------------------------------
  /addons:
    get:
      tags: [Account]
      summary: List available add-ons
      responses:
        "200": { description: addons, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/AddonDefinition" } } } } }
        "400": { description: brand required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /addons/subscriptions:
    get:
      tags: [Account]
      summary: List the customer's add-on subscriptions
      responses:
        "200": { description: subscriptions, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/AddonSubscription" } } } } }
        "500": { description: list failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /addons/subscribe:
    post:
      tags: [Account]
      summary: Subscribe to an add-on
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [addon_id]
              properties:
                addon_id: { type: string }
                resource_type: { type: string }
                resource_id: { type: string }
                quantity: { type: integer }
      responses:
        "201": { description: subscribed, content: { application/json: { schema: { $ref: "#/components/schemas/AddonSubscription" } } } }
        "400": { description: invalid body / purchase error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /addons/subscriptions/{id}:
    delete:
      tags: [Account]
      summary: Cancel an add-on subscription
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: cancelled, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: cancel error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Announcements (B-472) --------------------------------------------
  /announcements:
    get:
      tags: [Account]
      summary: List active announcements
      responses:
        "200": { description: announcements, content: { application/json: { schema: { type: object, properties: { announcements: { type: array, items: { $ref: "#/components/schemas/Announcement" } } } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /announcements/{id}/dismiss:
    post:
      tags: [Account]
      summary: Dismiss an announcement
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "204": { description: dismissed }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Cancellation requests (B-472) ------------------------------------
  /cancellation-requests:
    get:
      tags: [Orders]
      summary: List cancellation requests
      responses:
        "200": { description: requests, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/CancellationRequest" } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /cancellation-requests/{id}:
    delete:
      tags: [Orders]
      summary: Withdraw a cancellation request
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: withdrawn, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: withdraw error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Network blackhole / DDoS (B-472) ---------------------------------
  /network/blackhole:
    get:
      tags: [Networking]
      summary: List blackhole (DDoS null-route) entries
      parameters:
        - { name: active, in: query, schema: { type: string }, description: "active=true filters to active only" }
      responses:
        "200": { description: entries, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Blackhole" } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    post:
      tags: [Networking]
      summary: Blackhole a resource's IP
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resource_type, resource_id]
              properties:
                resource_type: { type: string, enum: [baremetal, floating_ip] }
                resource_id: { type: string }
                reason: { type: string }
      responses:
        "201": { description: created, content: { application/json: { schema: { $ref: "#/components/schemas/Blackhole" } } } }
        "400": { description: invalid body / no IP, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: resource not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: IP already blackholed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /network/blackhole/{id}:
    get:
      tags: [Networking]
      summary: Get a blackhole entry
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: entry, content: { application/json: { schema: { $ref: "#/components/schemas/Blackhole" } } } }
        "404": { description: not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      tags: [Networking]
      summary: Remove a blackhole entry
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "204": { description: removed }
        "404": { description: not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Credit notes / quotes / orders (B-472) ---------------------------
  /credit-notes:
    get:
      tags: [Orders]
      summary: List credit notes
      responses:
        "200": { description: credit notes, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/CreditNote" } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /quotes:
    get:
      tags: [Orders]
      summary: List quotes
      responses:
        "200": { description: quotes, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Quote" } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /quotes/{id}/accept:
    post:
      tags: [Orders]
      summary: Accept a quote
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: accepted, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: accept error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /orders:
    get:
      tags: [Orders]
      summary: List the customer's orders
      responses:
        "200": { description: orders, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Order" } } } } }
        "400": { description: brand required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Countries / brand config (B-472) ---------------------------------
  /countries:
    get:
      tags: [Account]
      summary: Allowed (non-sanctioned) countries
      security: []
      responses:
        "200": { description: countries, content: { application/json: { schema: { type: array, items: { type: object } } } } }
  /checkout/payment-methods:
    get:
      tags: [Checkout]
      summary: Which payment rails can serve this customer at this amount
      security: []
      description: >-
        Pre-flight for the order page. Answers the one question a storefront cannot
        answer for itself: a rail's corridor window is denominated in the CHARGE
        currency while you only know the billing total, and no conversion rate is
        exposed, so `startbutton_limits` and `mobile_money_limits` on /brand/config
        cannot be compared against your total.


        Call this before rendering the payment options and hide any rail returning
        available=false. Doing so avoids the one refusal that is NOT pre-commit: an
        over-cap order is rejected only after the order, invoice, discount and promo
        redemption exist, and that 400 is idempotency-cached for 24 hours, so a retry
        under a fresh key creates a second order with the promo already spent.


        Read-only and side-effect free. It uses the SAME predicates the checkout
        dispatch uses, so it cannot disagree with what /checkout will do. For an
        authenticated caller the account's stored country and currency are
        authoritative and the query parameters are ignored (B-301).
      parameters:
        - in: query
          name: amount
          schema: { type: number }
          description: >-
            Order total in the billing currency. Omit or send 0 to get the country
            decision only, with no amount check.
        - in: query
          name: currency
          schema: { type: string }
          description: Billing currency. Defaults to the brand's functional currency. Ignored for an authenticated caller.
        - in: query
          name: country
          schema: { type: string }
          description: ISO alpha-2, for a GUEST. Ignored when the caller is authenticated.
        - in: query
          name: pay_from_country
          schema: { type: string }
          description: Mobile Money corridor selection; honoured for pawapay only.
      responses:
        "200":
          description: per-rail availability
          content:
            application/json:
              schema:
                type: object
                properties:
                  currency: { type: string }
                  amount: { type: number }
                  payment_methods:
                    type: array
                    items:
                      type: object
                      properties:
                        gateway: { type: string }
                        available: { type: boolean }
                        reason:
                          type: string
                          description: country_not_supported | amount_out_of_range | fx_unavailable
                        message:
                          type: string
                          description: Customer-safe wording, identical to what checkout would return.
                        charge_currency:
                          type: string
                          description: The currency this rail would actually charge in after convert-first.
                        charge_amount:
                          type: number
                          description: The converted amount this rail would charge.

  /brand/config:
    get:
      tags: [Account]
      summary: Public brand config (theme, analytics, feature flags; no secrets)
      security: []
      responses:
        "200": { description: brand config, content: { application/json: { schema: { $ref: "#/components/schemas/BrandConfig" } } } }

  /csp-report:
    post:
      tags: [Account]
      summary: Content-Security-Policy violation report sink (browser-sent)
      description: >-
        Target of the frontend's `report-uri` (B-339). Accepts `application/csp-report`
        and `application/reports+json` as browsers send them; logged, never stored;
        always 204. Not for use by API clients.
      security: []
      requestBody:
        required: false
        content:
          application/csp-report: { schema: { type: object } }
          application/reports+json: { schema: { type: array, items: { type: object } } }
      responses:
        "204": { description: received }

  /networking/bgp-sessions:
    post:
      tags: [Networking]
      summary: Request a BGP/BYOIP session (structured, validated)
      description: >-
        Validates the request up front (ASN 1-4294967295; 1-10 CIDR prefixes written as
        network addresses, globally routable space only, IPv4 /8-/24, IPv6 /19-/48;
        target server must belong to you) then records it and auto-files a support
        ticket carrying the full details for our network team. Authorisation evidence
        is required - an RPKI ROA or IRR route-object reference, or a note that a LoA
        document will be attached to the ticket. Max 3 open requests.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [asn, prefixes, vm_id, loa]
              properties:
                asn: { type: integer }
                prefixes: { type: array, items: { type: string }, description: CIDR, one network per entry }
                vm_id: { type: string, description: the server the session terminates on }
                loa: { type: string, description: RPKI ROA / IRR reference or LoA note }
                notes: { type: string }
      responses:
        "201":
          content:
            application/json:
              schema:
                type: object
                properties:
                  session: { type: object }
                  ticket_id: { type: string }
    get:
      tags: [Networking]
      summary: List your BGP/BYOIP sessions (read-only registry)
      description: >-
        Sessions let you announce your own IP prefixes on our network via BGP to your
        server. This endpoint lists your sessions; new sessions are requested via a
        support ticket (include your ASN and the prefixes with proof of authorisation)
        and configured by our network team.
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string }
                    asn: { type: integer }
                    prefixes: { type: array, items: { type: string } }
                    status: { type: string, enum: [requested, active, disabled] }
                    vm_id: { type: string, nullable: true }
                    created_at: { type: string, format: date-time }

  /vat-rate:
    get:
      tags: [Account]
      summary: VAT rate for a country (and optional VAT id)
      security: []
      parameters:
        - { name: country, in: query, schema: { type: string, description: "2-letter ISO" } }
        - { name: vat_id, in: query, schema: { type: string } }
      responses:
        "200":
          description: rate
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VATRateResponse" }

  # ---- KB / docs (B-472) ------------------------------------------------
  /docs/articles:
    get:
      tags: [Account]
      summary: List KB articles (content blanked; excerpt only)
      security: []
      parameters:
        - { name: category, in: query, schema: { type: string } }
        - { name: search, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 20, minimum: 1, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer } }
      responses:
        "200": { description: articles, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Article" } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /docs/articles/{slug}:
    get:
      tags: [Account]
      summary: Get a KB article by slug (full content)
      security: []
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: article, content: { application/json: { schema: { $ref: "#/components/schemas/Article" } } } }
        "404": { description: not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /docs/categories:
    get:
      tags: [Account]
      summary: List KB categories
      security: []
      responses:
        "200": { description: categories, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Category" } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  # NOTE: GET /docs/export is INTERNAL (brand export-token auth, in-handler) — NOT a
  # customer endpoint. Documented in the internal spec (docs/api/openapi.yaml), kept
  # out of this public spec. Enforced by TestServedSpec_NoInternalGatedOps via the
  # middleware.InternalTokenAuth marker.
  /docs/articles/{id}/feedback:
    post:
      tags: [Account]
      summary: Submit anonymous article feedback
      security: []
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, properties: { helpful: { type: boolean } } }
      responses:
        "200": { description: ok, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: article not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Feedback (B-472) -------------------------------------------------
  /feedback/pending:
    get:
      tags: [Account]
      summary: Pending feedback prompts
      responses:
        "200": { description: pending, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Feedback" } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /feedback/{id}:
    post:
      tags: [Account]
      summary: Submit feedback
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rating]
              properties:
                rating: { type: integer, minimum: 1, maximum: 5 }
                comment: { type: string }
                would_recommend: { type: boolean }
                allow_public: { type: boolean }
                public_display_name: { type: string }
      responses:
        "200": { description: submitted, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /feedback/{id}/dismiss:
    post:
      tags: [Account]
      summary: Dismiss a feedback prompt
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: dismissed, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Notification preferences (B-472) ---------------------------------
  /notifications/preferences:
    get:
      tags: [Account]
      summary: Get notification preferences
      responses:
        "200": { description: preferences, content: { application/json: { schema: { type: object, properties: { preferences: { type: array, items: { $ref: "#/components/schemas/NotificationPref" } } } } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    put:
      tags: [Account]
      summary: Update notification preferences
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, properties: { preferences: { type: array, items: { $ref: "#/components/schemas/NotificationPref" } } } }
      responses:
        "204": { description: updated }
        "400": { description: invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Support ticket rating / attachments (B-472) ----------------------
  /support/tickets/{id}/rate:
    post:
      tags: [Support]
      summary: Rate a ticket
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [rating], properties: { rating: { type: integer, minimum: 1, maximum: 5 }, comment: { type: string } } }
      responses:
        "200": { description: rated, content: { application/json: { schema: { type: object, properties: { status: { type: string }, rating: { type: integer } } } } } }
        "400": { description: already rated / invalid, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: not owner, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: ticket not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /support/tickets/{id}/replies/{rid}/rate:
    post:
      tags: [Support]
      summary: Rate a staff reply
      parameters:
        - { $ref: "#/components/parameters/id" }
        - { name: rid, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [rating], properties: { rating: { type: integer, minimum: 1, maximum: 5 }, comment: { type: string } } }
      responses:
        "200": { description: rated, content: { application/json: { schema: { type: object, properties: { status: { type: string }, rating: { type: integer } } } } } }
        "400": { description: invalid / already rated, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: not owner, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: ticket or reply not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /support/tickets/{id}/unrated-replies:
    get:
      tags: [Support]
      summary: List unrated staff replies on a ticket
      parameters: [ { $ref: "#/components/parameters/id" } ]
      responses:
        "200": { description: replies, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Reply" } } } } }
        "403": { description: not owner, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: ticket not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /support/tickets/{id}/attachments/{replyId}/{filename}:
    get:
      tags: [Support]
      summary: Download a ticket attachment
      parameters:
        - { $ref: "#/components/parameters/id" }
        - { name: replyId, in: path, required: true, schema: { type: string } }
        - { name: filename, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: file (binary), content: { application/octet-stream: { schema: { type: string, format: binary } } } }
        "404": { description: not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- rDNS for any owned IP (B-472) ------------------------------------
  /ips/{id}/rdns:
    put:
      tags: [Networking]
      summary: Set reverse DNS (PTR) for any owned IP (IPv4)
      parameters: [ { $ref: "#/components/parameters/id" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, properties: { ptr: { type: string, description: "empty clears the PTR" } } }
      responses:
        "200": { description: updated IP, content: { application/json: { schema: { type: object } } } }
        "400": { description: invalid hostname / brand required, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: IP not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: IPv6 PTR unsupported, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Ads conversions feed / GDPR export / telemetry (B-472) -----------
  # NOTE: GET /ads/conversions is INTERNAL (brand export-token auth, in-handler) — the
  # Google Ads OCI feed, NOT a customer endpoint. Documented in the internal spec
  # (docs/api/openapi.yaml), kept out of this public spec. Enforced by
  # TestServedSpec_NoInternalGatedOps via the middleware.InternalTokenAuth marker.
  /auth/data-export:
    get:
      tags: [Account]
      summary: GDPR data export (JSON download)
      responses:
        "200": { description: export, content: { application/json: { schema: { type: object } } } }
        "500": { description: failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /t:
    post:
      tags: [Account]
      summary: Ingest first-party analytics events (unauthenticated, brand-scoped)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [visitor_id, session_id, events]
              properties:
                visitor_id: { type: string, maxLength: 64 }
                session_id: { type: string, maxLength: 64 }
                events:
                  type: array
                  minItems: 1
                  maxItems: 20
                  items:
                    type: object
                    properties:
                      type: { type: string, enum: [pageview, event, scroll] }
                      url: { type: string }
                      referrer: { type: string }
                      name: { type: string }
                      duration_ms: { type: integer }
                      scroll_depth: { type: integer }
                      ts: { type: integer, format: int64 }
                utm:
                  type: object
                  properties:
                    source: { type: string }
                    medium: { type: string }
                    campaign: { type: string }
                    content: { type: string }
                    term: { type: string }
                gclid: { type: string }
                twclid: { type: string }
      responses:
        "204": { description: accepted }
        "400": { description: invalid body / validation, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # ---- Checkout: promo + cart-resume (Path B) (B-472) -------------------
  /checkout/validate-promo:
    get:
      tags: [Orders]
      summary: Validate a promo code (always 200)
      security: []
      parameters:
        - { name: code, in: query, schema: { type: string } }
      responses:
        "200":
          description: result
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
                  reason: { type: string }
                  type: { type: string }
                  value: { type: number }
                  code: { type: string }
  # NOTE: POST /checkout/cart (server-to-server cart-resume mint, RequireBrandAPIKey)
  # is INTERNAL and deliberately NOT documented here. See TestServedSpec_NoInternalGatedOps.
  /checkout/cart/{token}:
    get:
      tags: [Orders]
      summary: Resolve a cart-resume token
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: cart, content: { application/json: { schema: { type: object, properties: { cart: { type: object }, expires_at: { type: string, format: date-time }, issued_at: { type: string, format: date-time } } } } } }
        "401": { description: invalid/expired token, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  # NOTE: POST /checkout (server-to-server storefront order, OrderKeyGate) is
  # INTERNAL and deliberately NOT documented here. See TestServedSpec_NoInternalGatedOps.


components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: JWT access token or API key (atlas_key_...)

  parameters:
    id:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid

  responses:
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Not authenticated
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UnprocessableEntity:
      description: Validation error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    NetworkMemberAdded:
      type: object
      properties:
        network_id: { type: string, format: uuid }
        vm_id: { type: string, format: uuid }
        private_ip: { type: string, format: ipv4 }
        created_at: { type: string, format: date-time }
        already_attached:
          type: boolean
          description: >
            Present and true on a 200: the server was already a member with this
            address and no interface was added.
        configure_in_guest:
          type: object
          properties:
            reason: { type: string, enum: [windows, appliance, not_running, guest_failed] }
            mac: { type: string }
            private_ip: { type: string, format: ipv4 }
            prefix_length: { type: integer }
    VATRateResponse:
      type: object
      description: Response of GET /v1/vat-rate.
      properties:
        country: { type: string, description: "2-letter ISO, echoed back" }
        rate: { type: number, description: "e.g. 21 for 21%. 0 when exempt or reverse-charged." }
        rate_type: { type: string, description: "standard | reverse_charge | exempt" }
        scheme: { type: string, description: "eu_oss | non_eu" }
        reverse_charge:
          type: boolean
          description: "True when the B2B reverse charge applies: you do not add VAT."
        vat_id_valid:
          type: boolean
          nullable: true
          description: "null when no vat_id was supplied, or VIES was unavailable (treat null as unknown, NOT as invalid)."
        vat_id_company: { type: string, description: "Company name from VIES, when it returns one." }
        vat_id_address: { type: string, description: "Company address from VIES, when it returns one." }
    Error:
      type: object
      description: >-
        Every non-2xx body. `error` is human-readable prose written for the dashboard
        and may be reworded at any time; `code` is a stable machine-readable reason
        drawn from a closed list, present on every error since 2026-09-03. Branch on
        `code`, never on `error`.
      required: [error, code]
      properties:
        error: { type: string }
        code:
          type: string
          enum:
            - invalid_request
            - missing_required_field
            - invalid_field_value
            - terms_not_accepted
            - unsupported_currency
            - invalid_redirect_url
            - recaptcha_required
            - recaptcha_failed
            - payload_too_large
            - password_policy
            - email_domain_not_allowed
            - product_unavailable
            - location_unsupported
            - gateway_unavailable
            - not_found
            - not_implemented
            - email_already_registered
            - account_suspended
            - read_only_session
            - currency_locked
            - currency_unconfirmed
            - country_not_supported
            - conflict
            - insufficient_balance
            - payment_required
            - payment_declined
            - rate_limited
            - authentication_required
            - forbidden
            - mfa_required
            - email_unverified
            - resource_not_ready
            - exchange_rate_unavailable
            - internal_error
            - gateway_error
            - idempotency_key_conflict
            - request_in_progress
            - caller_unauthorized

    TokenPair:
      type: object
      properties:
        access_token: { type: string }
        refresh_token: { type: string }

    Customer:
      type: object
      properties:
        id: { type: string, format: uuid }
        suggested_language:
          type: string
          description: >-
            Present only while the one-time language prompt should be shown: the locale the
            customer's country implies, when it differs from their language and they have not
            answered yet. Answer with POST /auth/language-prompt.
        email: { type: string, format: email }
        name: { type: string }
        company: { type: string }
        phone: { type: string }
        totp_enabled: { type: boolean }
        status: { type: string, enum: [active, suspended] }
        created_at: { type: string, format: date-time }

    AuditEntry:
      type: object
      description: >-
        One entry of the customer's own activity: actions taken on the account and its
        resources. Operations about the account that are internal to us are not listed,
        and no internal details are returned.
      properties:
        id: { type: string, format: uuid }
        actor: { type: string, description: "Your email, \"system\", or \"support\" for an action taken by our team." }
        action: { type: string }
        target_type: { type: string }
        target_id: { type: string }
        created_at: { type: string, format: date-time }

    APIKey:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        key_prefix: { type: string }
        scopes: { type: array, items: { type: string } }
        last_used: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    Session:
      type: object
      required: [id, customer_id, brand_id, device_info, ip_address, last_active_at, created_at, expires_at, is_current, browser, os, device_type]
      properties:
        id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        brand_id: { type: string, format: uuid }
        device_info: { type: string, description: "The raw User-Agent the session signed in with" }
        ip_address: { type: string }
        country: { type: string }
        city: { type: string }
        last_active_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        is_current: { type: boolean, description: "True for the session whose refresh-token hash the request sent in X-Session-Token-Hash" }
        browser: { type: string, description: "Parsed from device_info; empty when unknown" }
        os: { type: string, description: "Parsed from device_info; empty when unknown" }
        device_type: { type: string, enum: [desktop, mobile, tablet] }

    VM:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        hostname: { type: string }
        plan_id: { type: string, format: uuid }
        template_id: { type: string, format: uuid }
        location_id: { type: string, format: uuid }
        vcpus: { type: integer }
        ram_mb: { type: integer }
        disk_gb: { type: integer }
        state: { type: string, enum: [pending, provisioning, active, stopped, error, destroyed] }
        power_state: { type: string, nullable: true }
        billing_mode: { type: string, enum: [hourly, monthly] }
        ips: { type: array, items: { type: string } }
        error_message: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateVMRequest:
      type: object
      required: [plan_id, template_id, location_id, name]
      properties:
        plan_id: { type: string, format: uuid }
        template_id: { type: string, format: uuid }
        location_id: { type: string, format: uuid }
        name: { type: string }
        hostname: { type: string }
        ssh_keys: { type: string, description: "Public keys to install for root, comma-separated OpenSSH lines (ssh-ed25519, ssh-rsa, ecdsa-sha2-*); not key IDs" }
        user_data: { type: string, description: "cloud-init script" }
        billing_mode: { type: string, enum: [hourly, monthly], default: monthly }

    VMPlan:
      type: object
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        vcpus: { type: integer }
        ram_mb: { type: integer }
        disk_gb: { type: integer }
        bandwidth_tb: { type: number, nullable: true }
        category: { type: string }
        available: { type: boolean }
        price_hourly: { type: number }
        price_monthly: { type: number }

    OSTemplate:
      type: object
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        name: { type: string }
        family: { type: string }
        version: { type: string }
        arch: { type: string }
        min_disk_gb: { type: integer }
        cloud_init: { type: boolean }
        available: { type: boolean }

    Location:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        country: { type: string }
        available: { type: boolean }
        private_networking: { type: boolean, description: "Whether private networks can be created and attached in this location" }

    BaremetalSKU:
      type: object
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        name: { type: string }
        manufacturer: { type: string }
        model: { type: string }
        cpu_model: { type: string }
        cpu_count: { type: integer }
        cpu_cores: { type: integer }
        cpu_threads: { type: integer }
        ram_gb: { type: integer }
        storage_config: { type: string }
        available: { type: boolean }
        sort_order: { type: integer }
        pricing:
          type: array
          description: One entry per currency this brand prices the SKU in.
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              sku_id: { type: string, format: uuid }
              brand_id: { type: string, format: uuid }
              currency: { type: string }
              price_monthly: { type: number }
              setup_fee: { type: number }
              discount_quarterly: { type: number }
              discount_semiannual: { type: number }
              discount_annual: { type: number }
              discount_biennial: { type: number }
              discount_triennial: { type: number }
        stock:
          type: array
          description: >-
            Free machines per location. A location absent from the list has none; an
            order for it is refused with product_unavailable before any charge unless
            `fulfilment` lists it as build_to_order.
          items:
            type: object
            properties:
              location_id: { type: string }
              free: { type: integer }
        fulfilment:
          type: array
          description: >-
            How the SKU is sold per location. A location absent here does not sell it
            today. Show "in stock, ready in minutes" for a stock entry whose location has
            free machines and "built to order, ready by <promised_by>" for a build_to_order
            entry.
          items:
            type: object
            properties:
              location_id: { type: string }
              mode: { type: string, enum: [stock, build_to_order] }
              lead_time_business_days: { type: integer, description: "build_to_order only; business days are Monday to Friday." }
              promised_by: { type: string, format: date, description: "build_to_order only: the date an order placed today is promised by." }

    CustomerNotification:
      type: object
      properties:
        id: { type: string, format: uuid }
        channel: { type: string, description: email or sms }
        event: { type: string, description: "The kind of message, e.g. invoice_ready, payment_received, ticket_reply" }
        recipient: { type: string }
        status: { type: string, description: sent or failed on our side }
        delivery_status: { type: string, description: "The provider's verdict: delivered, bounced, dropped, deferred, or empty when none has arrived" }
        created_at: { type: string, format: date-time }
    CustomerPayment:
      type: object
      properties:
        id: { type: string, format: uuid }
        gateway: { type: string }
        status: { type: string }
        amount: { type: number }
        currency: { type: string }
        created_at: { type: string, format: date-time }
        checkout_url: { type: string }
        underpaid: { type: boolean }
        gateway_pay_amount: { type: string, description: Crypto amount the gateway asked for. }
        gateway_actually_paid: { type: string, description: Crypto amount the gateway received. }
        shortfall_percent: { type: number }
    BaremetalServer:
      type: object
      properties:
        id: { type: string, format: uuid }
        sku_id: { type: string, format: uuid }
        name: { type: string }
        hostname: { type: string }
        os: { type: string }
        location_id: { type: string, format: uuid }
        state: { type: string }
        power_state: { type: string, nullable: true }
        rescue_available:
          type: boolean
          description: Whether rescue mode can be requested on this platform. When false the rescue action is not offered.
        disk_layout:
          type: string
          enum: [raid1, independent]
          description: >-
            The disk layout chosen at order: raid1 mirrors the drives (usable capacity is
            one drive's), independent leaves each drive separate. Absent when no layout was
            recorded for the order.
        initial_password_available:
          type: boolean
          description: >-
            True while a one-time root password is held for this server (only when the
            order asked for one). Read it with GET /baremetal/servers/{id}/initial-password;
            remove it with DELETE on the same path. It is never sent by email.
        billing_mode: { type: string }
        ips: { type: array, items: { type: string } }
        ip_blocks:
          type: array
          description: >-
            Additional addressing the server owns: an on-link IPv4 block from its own
            subnet (usable range spelled out; same gateway as the primary address) and/or
            a routed IPv6 prefix, routed to the server's on-link IPv6 (route_via). Absent
            when the server has none.
          items:
            type: object
            properties:
              cidr: { type: string, example: "103.63.29.104/29" }
              kind: { type: string, enum: [onlink, routed] }
              family: { type: integer, enum: [4, 6] }
              gateway: { type: string, description: "On-link IPv4 blocks only." }
              route_via: { type: string, description: "Routed prefixes only: the on-link address the prefix is routed to." }
              usable_first: { type: string, description: "IPv4 only." }
              usable_last: { type: string, description: "IPv4 only." }
        error_message: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        next_due_date:
          type: string
          format: date-time
          description: Next renewal date, present once billing has started.
        fulfilment_mode:
          type: string
          enum: [stock, build_to_order]
          description: >-
            How the server is delivered. Absent on servers ordered before build-to-order
            existed, which are stock. A built-to-order server moves through
            awaiting_confirmation (a person confirms within one business day; nothing
            charged), pending_payment (confirmed, invoice raised), awaiting_hardware (paid;
            the machine is being sourced), then provisioning and deployed. Billing starts at
            handover. Until handover the order can be withdrawn with
            DELETE /baremetal/servers/{id}: free before payment, refunded in full after
            payment until hardware_ordered_at is set, refused from then on (the term
            applies). A withdrawn order reads state cancelled and is no longer listed. A
            confirmed order left unpaid for seven days expires; a payment that completes
            after that is returned in full to the account balance, never kept.
        confirmation_path:
          type: string
          enum: [auto, human]
          description: >-
            Built-to-order only. auto was confirmed at order; human is confirmed by a person.
            A customer whose company is not verified with us pays the first six months in
            advance before the hardware is ordered: such an order is confirmed only on a
            billing cycle of six months or longer (the twelve-month minimum term is unchanged).
        promised_by:
          type: string
          format: date
          description: >-
            Built-to-order only. The date the server is promised by. If a paid order is
            still waiting for its hardware after this date, you are emailed with a new date
            and this field moves to it; each missed date is announced once.
        notes:
          type: string
          description: The customer's order note, if any.
        confirmed_at: { type: string, format: date-time, description: "Built-to-order only: when the order was confirmed." }
        hardware_ordered_at: { type: string, format: date-time, description: "Built-to-order only: when the hardware was ordered for you. From then on the order cannot be cancelled for a refund; the twelve-month term applies from handover." }
        health:
          type: object
          nullable: true
          description: One-word verdict from the latest controller reading. Absent when no reading exists.
          properties:
            status: { type: string, enum: [healthy, warning, critical, unknown] }
            checked_at: { type: string, format: date-time }

        hardware:
          description: >-
            The machine as read from its management controller at handover: chassis,
            processors, memory modules, drives and network ports, each with its health.
            Absent until the first read. Prefer it over the SKU for specifications.
          type: object
          nullable: true
          properties:
            read_at: { type: string, format: date-time }
            manufacturer: { type: string }
            model: { type: string }
            health: { type: string }
            total_cores: { type: integer }
            total_threads: { type: integer }
            total_memory_gib: { type: number }
            processors: { type: array, items: { type: object } }
            memory_modules: { type: array, items: { type: object } }
            drives: { type: array, items: { type: object } }
            nics: { type: array, items: { type: object } }
    CreateServerRequest:
      type: object
      required: [sku_id, location_id, name]
      properties:
        sku_id: { type: string, format: uuid }
        location_id: { type: string, format: uuid }
        name: { type: string }
        hostname: { type: string }
        os: { type: string, description: "A MAAS series from GET /baremetal/os (e.g. ubuntu/noble). Empty installs the provider default." }
        ssh_keys: { type: string }
        user_data: { type: string }
        one_time_password: { type: boolean, description: "Root password shown only on the server page after install; forced change at first login. Not with user_data." }
        disk_layout: { type: string, enum: [raid1, independent], default: raid1 }
        storage_layout: { type: string, enum: [flat, lvm], default: flat }
        billing_mode: { type: string, enum: [monthly], default: monthly }
        billing_cycle: { type: string, enum: [monthly, quarterly, semiannual, annual, biennial, triennial], default: monthly }
        notes:
          type: string
          maxLength: 1000
          description: >-
            Optional note for the people handling the order (a deadline, an installation
            wish), never interpreted. On a built-to-order configuration a note means a
            person confirms the order before an invoice is raised.

    Wallet:
      type: object
      properties:
        id: { type: string, format: uuid }
        balance:
          type: number
          description: Wallet balance alone, excluding promotional credit.
        available:
          type: number
          description: >-
            Wallet PLUS unspent promotional credit: what the customer can actually
            spend, and the figure the server checks affordability against. Use this
            for any "can they afford it" test. Gating on `balance` alone refuses
            orders the API would accept, because settlement consumes credit first
            and then the wallet.
        currency: { type: string }
        updated_at: { type: string, format: date-time }

    Transaction:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { type: string }
        amount: { type: number }
        balance_after: { type: number }
        description: { type: string }
        created_at: { type: string, format: date-time }

    Invoice:
      type: object
      properties:
        id: { type: string, format: uuid }
        number: { type: string }
        status: { type: string, enum: [draft, pending, paid, void] }
        subtotal: { type: number }
        tax: { type: number }
        total: { type: number }
        currency: { type: string }
        period_start:
          type: string
          format: date-time
          nullable: true
          description: "Start of the service period the invoice covers: the earliest term its lines carry (since 2026-10-06, B-935); for older invoices whose lines carry no term, the invoice's own period."
        period_end:
          type: string
          format: date-time
          nullable: true
          description: "End of the service period (exclusive): the latest term its lines carry, with the same fallback as period_start."
        due_date: { type: string, format: date-time, nullable: true }
        paid_at: { type: string, format: date-time, nullable: true }
        line_items:
          type: array
          items: { $ref: "#/components/schemas/InvoiceLineItem" }
        created_at: { type: string, format: date-time }

    InvoiceLineItem:
      type: object
      properties:
        description: { type: string }
        resource_type: { type: string }
        resource_id: { type: string }
        quantity: { type: number }
        period_start:
          type: string
          format: date-time
          nullable: true
          description: "Start of the term this line pays for (renewal lines, B-888)."
        period_end:
          type: string
          format: date-time
          nullable: true
          description: "End of the term this line pays for, exclusive."
        unit_price: { type: number }
        total: { type: number }

    UsageRecord:
      type: object
      properties:
        id: { type: string, format: uuid }
        resource_type: { type: string }
        resource_id: { type: string }
        hour: { type: string, format: date-time }
        quantity: { type: number }
        unit_price: { type: number }
        total: { type: number }

    Volume:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        location_id: { type: string, format: uuid }
        size_gb: { type: integer }
        attached_to: { type: string, format: uuid, nullable: true }
        state: { type: string }
        billing_mode: { type: string }
        created_at: { type: string, format: date-time }

    Bucket:
      type: object
      properties:
        id: { type: string, format: uuid }
        brand_id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        name: { type: string }
        location_id: { type: string }
        access_key: { type: string }
        secret_key:
          type: string
          description: Present only in the 201 response of a create. Retrievable again from the credentials route.
        endpoint: { type: string }
        region: { type: string, description: SigV4 signing region (eu-lis-1) }
        managed_by:
          type: string
          description: Present when a product owns the bucket (bm_backup, the bare-metal backup add-on). Such a bucket cannot be deleted here; cancel the add-on instead (409 on DELETE).
        size_bytes: { type: integer }
        state: { type: string }
        created_at: { type: string, format: date-time }

    FloatingIP:
      type: object
      properties:
        id: { type: string, format: uuid }
        address: { type: string }
        assigned_to: { type: string, format: uuid, nullable: true }
        resource_type: { type: string, nullable: true }
        is_floating: { type: boolean }
        rdns: { type: string, nullable: true }
        location_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }

    PrivateNetwork:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        location_id: { type: string, format: uuid }
        subnet: { type: string }
        vlan_id: { type: integer }
        state: { type: string }
        members:
          type: array
          items:
            type: object
            properties:
              vm_id: { type: string, format: uuid }
              private_ip: { type: string }
              created_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    DNSZone:
      type: object
      properties:
        id: { type: string, format: uuid }
        domain: { type: string }
        state: { type: string }
        created_at: { type: string, format: date-time }

    DNSRecord:
      type: object
      properties:
        id: { type: string, format: uuid }
        zone_id: { type: string, format: uuid }
        name: { type: string }
        type: { type: string, enum: [A, AAAA, CNAME, MX, TXT, NS, SRV, CAA] }
        content: { type: string }
        ttl: { type: integer }
        priority: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }

    CreateDNSRecordRequest:
      type: object
      required: [name, type, content, ttl]
      properties:
        name: { type: string }
        type: { type: string, enum: [A, AAAA, CNAME, MX, TXT, NS, SRV, CAA] }
        content: { type: string }
        ttl: { type: integer, default: 3600 }
        priority: { type: integer }

    Snapshot:
      type: object
      properties:
        id: { type: string, format: uuid }
        vm_id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string }
        size_bytes: { type: integer, nullable: true }
        state: { type: string }
        created_at: { type: string, format: date-time }

    Backup:
      type: object
      properties:
        id: { type: string, format: uuid }
        vm_id: { type: string, format: uuid }
        size_bytes: { type: integer, nullable: true }
        state: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, nullable: true }

    BackupSchedule:
      type: object
      properties:
        id: { type: string, format: uuid }
        vm_id: { type: string, format: uuid }
        frequency: { type: string, enum: [daily] }
        retention: { type: integer }
        enabled: { type: boolean }
        next_run: { type: string, format: date-time, nullable: true }
        last_run: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    SSHKey:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        public_key: { type: string }
        fingerprint: { type: string }
        created_at: { type: string, format: date-time }

    Ticket:
      type: object
      properties:
        id: { type: string, format: uuid }
        subject: { type: string }
        status: { type: string, enum: [open, answered, closed] }
        priority: { type: string, enum: [low, medium, high] }
        department: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TicketWithReplies:
      allOf:
        - $ref: "#/components/schemas/Ticket"
        - type: object
          properties:
            replies:
              type: array
              items: { $ref: "#/components/schemas/Reply" }

    Reply:
      type: object
      properties:
        id: { type: string, format: uuid }
        ticket_id: { type: string, format: uuid }
        author_type: { type: string, enum: [customer, staff] }
        author_id: { type: string }
        body: { type: string }
        created_at: { type: string, format: date-time }

    Team:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        owner_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }

    TeamWithMembers:
      allOf:
        - $ref: "#/components/schemas/Team"
        - type: object
          properties:
            members:
              type: array
              items: { $ref: "#/components/schemas/TeamMember" }

    TeamMember:
      type: object
      properties:
        customer_id: { type: string, format: uuid }
        email: { type: string }
        name: { type: string }
        role: { type: string, enum: [admin, member, viewer] }
        created_at: { type: string, format: date-time }

    FirewallRule:
      type: object
      properties:
        id: { type: string, format: uuid }
        direction: { type: string, enum: [inbound, outbound] }
        action: { type: string, enum: [accept, drop] }
        protocol: { type: string, enum: [tcp, udp, icmp] }
        port: { type: string }
        source: { type: string }
        sort_order: { type: integer }
        enabled: { type: boolean }

    FirewallRuleInput:
      type: object
      required: [direction, action]
      properties:
        direction: { type: string, enum: [inbound, outbound] }
        action: { type: string, enum: [accept, drop] }
        protocol: { type: string, enum: [tcp, udp, icmp] }
        port: { type: string }
        source: { type: string }
        enabled: { type: boolean, default: true }

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }

    WebhookEndpointWithSecret:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpoint"
        - type: object
          properties:
            secret: { type: string, description: "HMAC signing secret (shown only once)" }

    WebhookDelivery:
      type: object
      properties:
        id: { type: string, format: uuid }
        endpoint_id: { type: string, format: uuid }
        event: { type: string }
        payload: { type: object }
        status_code: { type: integer, nullable: true }
        response_body: { type: string, nullable: true }
        attempts: { type: integer }
        created_at: { type: string, format: date-time }

    ColoAssignment:
      type: object
      description: |
        A colocation service. Power is sold as a flat allowance and is never metered, so
        the allowance is the contract, not a measurement. Port status and speed are the
        latest observation from our switch.
      properties:
        id: { type: string, format: uuid }
        label: { type: string }
        state: { type: string, enum: [pending_installation, active, suspended, ended] }
        location_id: { type: string }
        location_name: { type: string }
        tier_slug: { type: string }
        tier_name: { type: string }
        rack: { type: string }
        u_position: { type: integer }
        u_height: { type: integer }
        power_allowance_avg_w: { type: integer }
        power_allowance_peak_w: { type: integer }
        port_speed_mbps: { type: integer, description: Contracted port speed }
        commit_mbps: { type: integer, description: Committed rate; transit above it is billed at the 95th percentile }
        currency: { type: string }
        price_monthly: { type: number }
        transit_price_per_mbps: { type: number }
        term_months: { type: integer }
        term_start: { type: string, format: date-time }
        term_end: { type: string, format: date-time }
        next_due_date: { type: string, format: date-time }
        ended_at: { type: string, format: date-time, description: Set once the contract has ended }
        hold_until: { type: string, format: date, description: "After the end, the last day we hold your hardware for collection; your IPv4 addresses are released then" }
        prefixes: { type: array, items: { type: string }, description: Your prefixes routed to you, such as a sponsored PI /48 }
        asn: { type: integer, format: int64 }
        management_access_ready: { type: boolean, description: Your WireGuard peer to your own management VLAN is provisioned }
        management_vlan: { type: integer }
        port:
          type: object
          properties:
            up: { type: boolean }
            speed_mbps: { type: integer }
            observed_at: { type: string, format: date-time }

    ColoAssignmentDetail:
      allOf:
        - $ref: "#/components/schemas/ColoAssignment"
        - type: object
          properties:
            addresses:
              type: array
              items:
                type: object
                properties:
                  id: { type: string, format: uuid, description: "Use with PUT /ips/{id}/rdns" }
                  address: { type: string }
                  rdns: { type: string }
                  rdns_editable: { type: boolean }

    ColoTraffic:
      type: object
      properties:
        range: { type: string, enum: [day, week, month] }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        bucket_seconds: { type: integer }
        points:
          type: array
          items:
            type: object
            properties:
              at: { type: string, format: date-time }
              inbound_mbps: { type: number, description: Toward your hardware }
              outbound_mbps: { type: number, description: From your hardware }
        month_pct95_mbps: { type: number }
        month_samples: { type: integer }
        commit_mbps: { type: integer }

    ColoRequestInput:
      type: object
      required: [kind, description]
      properties:
        kind: { type: string, enum: [remote_hands, delivery] }
        description: { type: string, maxLength: 2000 }
        hours: { type: integer, minimum: 1, maximum: 24, description: Remote hands only; one-hour minimum }
        carrier: { type: string, description: Delivery only }
        tracking: { type: string, description: Delivery only }
        expected_on: { type: string, format: date, description: Delivery only }
        packages: { type: integer, minimum: 1, maximum: 50, description: Delivery only }

    ColoServiceRequest:
      type: object
      properties:
        id: { type: string, format: uuid }
        assignment_id: { type: string, format: uuid }
        brand_id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        kind: { type: string, enum: [remote_hands, delivery] }
        state: { type: string, enum: [awaiting_acceptance, accepted, completed, withdrawn, cancelled] }
        description: { type: string }
        hours: { type: integer }
        carrier: { type: string }
        tracking: { type: string }
        expected_on: { type: string, format: date-time }
        packages: { type: integer }
        currency: { type: string }
        rate_cents: { type: integer, format: int64 }
        price_cents: { type: integer, format: int64 }
        accepted_at: { type: string, format: date-time }
        accepted_price_cents: { type: integer, format: int64 }
        ticket_id: { type: string }
        reference: { type: string, description: Delivery only; label the box with it }
        completed_at: { type: string, format: date-time }
        cancelled_reason: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ColoRequests:
      type: object
      properties:
        prices:
          type: object
          properties:
            currency: { type: string }
            remote_hands_hour_cents: { type: integer, format: int64 }
            delivery_registration_cents: { type: integer, format: int64 }
        requests:
          type: array
          items: { $ref: "#/components/schemas/ColoServiceRequest" }

    BandwidthSample:
      type: object
      properties:
        id: { type: string, format: uuid }
        server_id: { type: string, format: uuid }
        inbound_mbps: { type: number }
        outbound_mbps: { type: number }
        sampled_at: { type: string, format: date-time }

    AffiliateConversion:
      type: object
      properties:
        id: { type: string, format: uuid }
        brand_id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        conversion_type: { type: string, enum: [signup, first_payment] }
        value_cents: { type: integer }
        referral_code: { type: string }
        click_id: { type: string }
        utm_source: { type: string }
        utm_medium: { type: string }
        utm_campaign: { type: string }
        postback_sent: { type: boolean }
        created_at: { type: string, format: date-time }

    Integration:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [slack, discord] }
        webhook_url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }

    # ── Marketplace Schemas ──────────────────────────────────────
    AppTemplate:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: "Docker" }
        slug: { type: string, example: "docker" }
        description: { type: string }
        category:
          type: string
          enum: [web, database, devops, analytics, cms, ecommerce, networking]
        icon: { type: string, description: "Icon name or URL" }
        min_vcpus: { type: integer, example: 1 }
        min_memory_mb: { type: integer, example: 1024 }
        min_disk_gb: { type: integer, example: 20 }
        supported_os_families:
          type: array
          items: { type: string }
          example: [ubuntu, debian]
          description: >-
            Operating system families this application is compatible with. This
            is informational: the application deploys on the specific image named
            by os_template_slug, and you do not choose an operating system.
        plan_slug:
          type: string
          example: "chr-1"
          description: >-
            When present, the application deploys on this specific plan and
            plan_id is ignored. Set only where the plan is part of the product,
            such as an appliance. Ordinary applications leave this empty and you
            choose how much server to buy.
        os_template_slug:
          type: string
          example: "ubuntu-24.04-x64"
          description: >-
            The exact operating system image this application deploys on. The
            application carries its own image, so you do not select one.
        deploy_ready:
          type: boolean
          description: >-
            Whether this application can be deployed today. False means it has
            not yet been proven end to end on a real server, and a deployment
            request will be refused with 503. Readiness is per application: one
            being unavailable says nothing about the others.
        variables:
          type: array
          items: { $ref: "#/components/schemas/AppTemplateVariable" }
        access_instructions: { type: string, description: "Post-deployment access guide" }
        default_ports:
          type: array
          items: { type: integer }
          example: [80, 443]
        author: { type: string }
        documentation_url: { type: string, format: uri }
        display_order: { type: integer }
        created_at: { type: string, format: date-time }

    AppTemplateVariable:
      type: object
      properties:
        name: { type: string, example: "MYSQL_ROOT_PASSWORD" }
        label: { type: string, example: "MySQL Root Password" }
        type:
          type: string
          enum: [string, password, number, boolean]
        default: { type: string }
        required: { type: boolean }
        validation: { type: string, description: "Regex pattern for validation" }

    DeployAppRequest:
      type: object
      required: [template, location_id, name]
      properties:
        template: { type: string, description: "App template slug or ID", example: "docker" }
        plan_id:
          type: string
          example: "vm2"
          description: >-
            Optional when the application pins its own plan, required otherwise.
            A value supplied here is ignored if the application is pinned.
        template_id:
          type: string
          example: "ubuntu-24.04-x64"
          description: >-
            Optional. The application deploys on its own pinned image, so this is
            normally omitted. When the application is pinned, a value supplied
            here is ignored in favour of that image.
        location_id: { type: string, description: "Location slug", example: "eu-ams-1" }
        name: { type: string, description: "VM name", example: "my-docker-server" }
        hostname: { type: string }
        ssh_keys: { type: string, description: "SSH public keys (newline-separated)" }
        variables:
          type: object
          additionalProperties: { type: string }
          description: "Template variable values"
          example:
            DOCKER_COMPOSE_VERSION: "2.24.0"
        billing_mode:
          type: string
          enum: [hourly, monthly]
          default: monthly
        billing_cycle:
          type: string
          enum: [monthly, quarterly, semiannual, annual, biennial, triennial]

    DeployAppResponse:
      type: object
      properties:
        deployment_id: { type: string, format: uuid }
        vm_id: { type: string, format: uuid }
        status: { type: string, example: "running" }

    AppDeployment:
      type: object
      properties:
        id: { type: string, format: uuid }
        template_id: { type: string, format: uuid }
        vm_id: { type: string, format: uuid, nullable: true }
        customer_id: { type: string, format: uuid }
        variables_used:
          type: object
          additionalProperties: { type: string }
        status:
          type: string
          enum: [pending, running, completed, failed]
        error_message: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true }

    Availability:
      type: object
      properties:
        name: { type: string }
        available: { type: boolean }
        reason: { type: string }

    PriceResult:
      type: object
      description: Retail price in the customer's currency. cents is int64 minor units.
      properties:
        cents: { type: integer, format: int64 }
        currency: { type: string }
        premium: { type: boolean }
        source: { type: string, description: "table | registry_premium | promo | aftermarket" }

    PriceSummary:
      type: object
      description: Full per-name pricing picture. All *_cents are int64 minor units.
      properties:
        name: { type: string }
        available: { type: boolean }
        reason: { type: string }
        currency: { type: string }
        first_year_cents: { type: integer, format: int64 }
        regular_first_year_cents: { type: integer, format: int64 }
        renew_cents: { type: integer, format: int64 }
        transfer_cents: { type: integer, format: int64 }
        premium: { type: boolean }
        promo: { type: boolean }
        min_term_years: { type: integer }
        from_total_cents: { type: integer, format: int64, description: "floored minimum the buyer pays for min_term_years; display this for gated names to match checkout" }

    Suggestion:
      type: object
      description: One ranked alternative. *_cents are int64 minor units.
      properties:
        name: { type: string }
        tld: { type: string }
        available: { type: boolean }
        price_cents: { type: integer, format: int64 }
        renew_cents: { type: integer, format: int64 }
        currency: { type: string }
        premium: { type: boolean }
        aftermarket: { type: boolean }
        price_pending: { type: boolean }
        source: { type: string, description: "anchor | cluster | geo | featured | variation | aftermarket | ai" }
        reason: { type: string }
        min_term_years: { type: integer }
        from_total_cents: { type: integer, format: int64, description: "floored minimum for min_term_years; display for gated rows instead of price_cents" }

    SuggestionsResponse:
      type: object
      properties:
        query:
          type: object
          description: searched name's availability/price (best-effort)
          properties:
            name: { type: string }
            available: { type: boolean }
            reason: { type: string }
            price: { $ref: "#/components/schemas/PriceResult" }
        suggestions:
          type: array
          items: { $ref: "#/components/schemas/Suggestion" }

    Domain:
      type: object
      description: A registered domain (the domainResponse wire projection).
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        tld: { type: string }
        registrar: { type: string }
        status: { type: string, enum: [pending_register, active, expired, redemption, pending_transfer, failed, deleted] }
        auto_renew: { type: boolean }
        locked: { type: boolean }
        whois_privacy: { type: boolean }
        nameservers: { type: array, items: { type: string } }
        registered_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        uses_atlas_ns: { type: boolean }
        invoice_id:
          type: string
          nullable: true
          description: >-
            The unpaid invoice behind a domain still in pending_register, so a
            client can send the customer to pay it. Absent once the domain is active.
        zone_id: { type: string, format: uuid, nullable: true }

    DomainOrderResponse:
      type: object
      description: Result of a register/transfer/renew/restore. Pay the invoice to provision.
      properties:
        domain_id: { type: string, format: uuid }
        order_id: { type: string }
        invoice_id: { type: string }
        status: { type: string }
        amount_cents: { type: integer, format: int64 }
        currency: { type: string }
        premium: { type: boolean }

    DomainBatchResponse:
      type: object
      properties:
        order_id: { type: string }
        invoice_id: { type: string }
        status: { type: string }
        count: { type: integer }
        domains: { type: array, items: { type: string } }
        total_cents: { type: integer, format: int64 }
        currency: { type: string }

    DomainPriceChanged:
      type: object
      description: 409 body when the live price no longer matches the quoted agreed_amount_cents.
      properties:
        error: { type: string, description: "price_changed | aftermarket_unavailable | aftermarket_enquiry_required" }
        name: { type: string, description: present on batch (which line changed) }
        amount_cents: { type: integer, format: int64 }
        retail_cents: { type: integer, format: int64 }
        currency: { type: string }
        premium: { type: boolean }

    ContactRequest:
      type: object
      description: Registrant contact for an order. email, name, country are required.
      required: [email, name, country]
      properties:
        email: { type: string }
        name: { type: string }
        org: { type: string }
        phone: { type: string }
        fax: { type: string }
        street: { type: string }
        street2: { type: string }
        city: { type: string }
        province: { type: string }
        postal_code: { type: string }
        country: { type: string, description: 2-letter ISO }

    GlueRequest:
      type: object
      properties:
        host: { type: string }
        ipv4: { type: array, items: { type: string } }
        ipv6: { type: array, items: { type: string } }

    RegisterDomainRequest:
      type: object
      required: [name, years, contact]
      properties:
        name: { type: string }
        years: { type: integer, minimum: 1, maximum: 10 }
        auto_renew: { type: boolean }
        use_atlas_ns: { type: boolean, description: "when false, at least 2 nameservers are required" }
        nameservers: { type: array, items: { type: string } }
        contact: { $ref: "#/components/schemas/ContactRequest" }
        glue: { type: array, items: { $ref: "#/components/schemas/GlueRequest" } }
        agreed_amount_cents: { type: integer, format: int64, nullable: true, description: quoted total; a live-price mismatch returns 409 price_changed }
        aftermarket: { type: boolean }
        save_contact: { type: boolean }
        contact_label: { type: string }

    BatchRegisterRequest:
      type: object
      required: [domains, contact]
      properties:
        domains:
          type: array
          minItems: 1
          maxItems: 20
          items:
            type: object
            required: [name]
            properties:
              name: { type: string }
              years: { type: integer, minimum: 1, maximum: 10 }
              agreed_amount_cents: { type: integer, format: int64, nullable: true }
        auto_renew: { type: boolean }
        use_atlas_ns: { type: boolean }
        nameservers: { type: array, items: { type: string } }
        glue: { type: array, items: { $ref: "#/components/schemas/GlueRequest" } }
        contact: { $ref: "#/components/schemas/ContactRequest" }

    TransferDomainRequest:
      type: object
      required: [name, years, contact]
      properties:
        name: { type: string }
        years: { type: integer, minimum: 1, maximum: 10 }
        auth_code: { type: string }
        agreed_amount_cents: { type: integer, format: int64, nullable: true }
        contact: { $ref: "#/components/schemas/ContactRequest" }
        save_contact: { type: boolean }
        contact_label: { type: string }

    RenewDomainRequest:
      type: object
      required: [years]
      properties:
        years: { type: integer, minimum: 1, maximum: 10 }
        agreed_amount_cents: { type: integer, format: int64, nullable: true }

    RestoreDomainRequest:
      type: object
      properties:
        agreed_amount_cents: { type: integer, format: int64, nullable: true }

    SetNameserversRequest:
      type: object
      properties:
        nameservers: { type: array, items: { type: string }, description: at least 2 unless use_atlas_ns is true }
        use_atlas_ns: { type: boolean }

    ContactBookRequest:
      type: object
      required: [label, name, email]
      properties:
        label: { type: string }
        name: { type: string }
        org: { type: string }
        email: { type: string }
        phone: { type: string }
        street: { type: string }
        city: { type: string }
        province: { type: string }
        postal_code: { type: string }
        country: { type: string, description: optional; 2-letter ISO if present }

    ContactBookEntry:
      type: object
      properties:
        id: { type: string, format: uuid }
        label: { type: string }
        name: { type: string }
        org: { type: string }
        email: { type: string }
        phone: { type: string }
        street: { type: string }
        city: { type: string }
        province: { type: string }
        postal_code: { type: string }
        country: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    VMExecResult:
      type: object
      properties:
        exit_code: { type: integer }
        stdout: { type: string }
        stderr: { type: string }

    VMGuestInfo:
      type: object
      properties:
        agent_running: { type: boolean }
        os_info:
          type: object
          nullable: true
          properties:
            id: { type: string }
            name: { type: string }
            pretty-name: { type: string }
            version: { type: string }
            version-id: { type: string }
            machine: { type: string }
            kernel-release: { type: string }
            kernel-version: { type: string }
        networks:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              hardware-address: { type: string }
              ip-addresses:
                type: array
                items:
                  type: object
                  properties:
                    ip-address-type: { type: string }
                    ip-address: { type: string }
        filesystems:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              mountpoint: { type: string }
              type: { type: string }
              total-bytes: { type: integer, format: int64 }
              used-bytes: { type: integer, format: int64 }
              disk: { type: array, items: { type: string } }

    VMBandwidthSample:
      type: object
      properties:
        id: { type: string }
        vm_id: { type: string }
        inbound_bytes: { type: integer, format: int64 }
        outbound_bytes: { type: integer, format: int64 }
        sampled_at: { type: string, format: date-time }

    VMBandwidthUsage:
      type: object
      properties:
        samples: { type: array, items: { $ref: "#/components/schemas/VMBandwidthSample" } }
        total_inbound_bytes: { type: integer, format: int64 }
        total_outbound_bytes: { type: integer, format: int64 }
        total_bytes: { type: integer, format: int64 }
        bandwidth_cap_tb: { type: number, nullable: true }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }

    ResizePreview:
      type: object
      properties:
        new_plan_id: { type: string }
        new_plan_slug: { type: string }
        vcpus: { type: integer }
        ram_mb: { type: integer }
        disk_gb: { type: integer }
        bandwidth_tb: { type: number, nullable: true }
        currency: { type: string }
        net_cents: { type: integer, format: int64 }
        subtotal_cents: { type: integer, description: The proration before tax. }
        tax_cents: { type: integer, description: Tax on the proration, charged like a purchase (B-443). }
        is_upgrade: { type: boolean }
        wallet_balance_cents: { type: integer, format: int64 }
        sufficient: { type: boolean }
        must_be_stopped: { type: boolean }

    ISOInfo:
      type: object
      properties:
        volid: { type: string }
        name: { type: string, description: "The filename as you uploaded it. Storage carries an ownership prefix that is not shown." }
        size: { type: integer, format: int64 }
        owned: { type: boolean, description: "True when you uploaded this image yourself. Only your own images can be deleted; images provided by AFRICLOUD are shared." }
        mounted: { type: boolean, description: "True when this image is attached to the server right now, read from the hypervisor on every request. A mounted image cannot be deleted: unmount it first." }

    VMAlert:
      type: object
      properties:
        id: { type: string }
        vm_id: { type: string }
        customer_id: { type: string }
        brand_id: { type: string }
        metric: { type: string, enum: [cpu, ram, disk, netin, netout] }
        operator: { type: string, enum: [">", ">=", "<", "<=", "=="] }
        threshold: { type: number }
        cooldown_minutes: { type: integer }
        last_triggered_at: { type: string, format: date-time, nullable: true }
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }

    CreateVMAlertRequest:
      type: object
      required: [metric, operator, threshold]
      properties:
        metric: { type: string, enum: [cpu, ram, disk, netin, netout] }
        operator: { type: string, enum: [">", ">=", "<", "<=", "=="] }
        threshold: { type: number }
        cooldown_minutes: { type: integer, description: "defaults to 60 when <= 0" }

    PowerSchedule:
      type: object
      properties:
        id: { type: string }
        vm_id: { type: string }
        customer_id: { type: string }
        action: { type: string, enum: [start, stop, reboot] }
        cron_expression: { type: string }
        enabled: { type: boolean }
        last_run_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreatePowerScheduleRequest:
      type: object
      required: [action, cron_expression]
      properties:
        action: { type: string, enum: [start, stop, reboot] }
        cron_expression: { type: string }

    CancelRequest:
      type: object
      properties:
        cancel_type: { type: string, enum: [immediate, end_of_period], description: "defaults to immediate" }
        reason: { type: string }

    CancellationRequest:
      type: object
      properties:
        id: { type: string }
        brand_id: { type: string }
        customer_id: { type: string }
        resource_type: { type: string }
        resource_id: { type: string }
        cancel_type: { type: string }
        reason: { type: string }
        status: { type: string }
        scheduled_at: { type: string, format: date-time, nullable: true }
        processed_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    EdgeFirewallRule:
      type: object
      properties:
        id: { type: string }
        customer_id: { type: string }
        brand_id: { type: string }
        resource_type: { type: string }
        resource_id: { type: string }
        action: { type: string, enum: [allow, deny] }
        protocol: { type: string, nullable: true }
        source_cidr: { type: string, nullable: true }
        destination_port: { type: string, nullable: true }
        routeros_id: { type: string, nullable: true }
        routeros_comment: { type: string, nullable: true }
        description: { type: string, nullable: true }
        is_enabled: { type: boolean }
        sort_order: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    EdgeFirewallRuleInput:
      type: object
      required: [action]
      properties:
        action: { type: string, enum: [allow, deny] }
        protocol: { type: string, enum: [tcp, udp, icmp, any], nullable: true }
        source_cidr: { type: string, nullable: true, description: "CIDR or IP" }
        destination_port: { type: string, nullable: true }
        description: { type: string, nullable: true }

    PaymentMethod:
      type: object
      properties:
        id: { type: string }
        brand_id: { type: string }
        customer_id: { type: string }
        gateway: { type: string }
        type: { type: string }
        display_brand: { type: string }
        display_last4: { type: string }
        display_exp_month: { type: integer }
        display_exp_year: { type: integer }
        display_email: { type: string }
        is_default: { type: boolean }
        status: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AddPaymentMethodResponse:
      type: object
      description: Setup handles for the chosen gateway (fields present depend on gateway).
      properties:
        client_secret: { type: string }
        checkout_url: { type: string }
        setup_intent_id: { type: string }
        publishable_key: { type: string, description: "Stripe only" }

    Subscription:
      type: object
      description: A recurring resource subscription (gateway ids are not exposed).
      properties:
        id: { type: string }
        brand_id: { type: string }
        customer_id: { type: string }
        resource_type: { type: string }
        resource_id: { type: string }
        gateway: { type: string }
        payment_method_id: { type: string, nullable: true }
        status: { type: string }
        current_period_start: { type: string, format: date-time, nullable: true }
        current_period_end: { type: string, format: date-time, nullable: true }
        cancel_at_period_end: { type: boolean }
        cancelled_at: { type: string, format: date-time, nullable: true }
        cancellation_reason: { type: string }
        metadata: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    VATStatus:
      type: object
      properties:
        has_validation: { type: boolean }
        vat_verified: { type: boolean }
        vat_id: { type: string }
        validation:
          type: object
          nullable: true
          properties:
            vat_number: { type: string }
            country_code: { type: string }
            company_name: { type: string }
            is_valid: { type: boolean }
            validated_at: { type: string, format: date-time }

    VATValidation:
      type: object
      properties:
        valid: { type: boolean }
        pending: { type: boolean }
        country_code: { type: string }
        vat_number: { type: string }
        company_name: { type: string }
        address: { type: string }
        validated_at: { type: string, format: date-time }
        error: { type: string }

    Consent:
      type: object
      properties:
        id: { type: string }
        customer_id: { type: string }
        brand_id: { type: string }
        consent_type: { type: string, enum: [terms, privacy, marketing, analytics] }
        granted: { type: boolean }
        ip_address: { type: string }
        user_agent: { type: string }
        created_at: { type: string, format: date-time }

    ResetTokenInfo:
      type: object
      properties:
        customer_id: { type: string }
        totp_enabled: { type: boolean }
        has_recovery_codes: { type: boolean }
        valid: { type: boolean }
        error: { type: string }

    CreditSummary:
      type: object
      properties:
        total_credits: { type: integer }
        total_balance_cents: { type: integer, format: int64 }
        expiring_within_30d_cents: { type: integer, format: int64 }
        next_expiry: { type: string, format: date-time, nullable: true }

    TopupCap:
      type: object
      properties:
        cap_cents: { type: integer, format: int64 }
        remaining_cents: { type: integer, format: int64 }
        period_start: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
        has_full_kyc: { type: boolean }
        message: { type: string }

    VerificationStatus:
      type: object
      properties:
        tier: { type: integer }
        tier_name: { type: string }
        email_verified: { type: boolean }
        email_verified_at: { type: string, format: date-time, nullable: true }
        phone_verified: { type: boolean }
        phone_verified_at: { type: string, format: date-time, nullable: true }
        identity_verified: { type: boolean }
        identity_verified_at: { type: string, format: date-time, nullable: true }
        company_verified: { type: boolean }
        company_verified_at: { type: string, format: date-time, nullable: true }
        is_flagged: { type: boolean }
        flag_reason: { type: string }
        total_spend_cents: { type: integer, format: int64 }
        pending_requirements: { type: array, items: { type: object } }
        promotional_credits: { $ref: "#/components/schemas/CreditSummary" }

    AddonDefinition:
      type: object
      properties:
        id: { type: string }
        brand_id: { type: string }
        slug: { type: string }
        name: { type: string }
        description: { type: string }
        resource_type: { type: string }
        billing_cycle: { type: string }
        price: { type: number }
        price_cents: { type: integer, format: int64 }
        currency: { type: string }
        setup_fee: { type: number }
        available: { type: boolean }
        sort_order: { type: integer }
        created_at: { type: string, format: date-time }

    AddonSubscription:
      type: object
      properties:
        id: { type: string }
        brand_id: { type: string }
        customer_id: { type: string }
        addon_id: { type: string }
        addon_name: { type: string }
        addon_slug: { type: string }
        resource_type: { type: string }
        resource_id: { type: string, nullable: true }
        quantity: { type: integer }
        status: { type: string }
        next_due_date: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        cancelled_at: { type: string, format: date-time, nullable: true }

    Announcement:
      type: object
      properties:
        id: { type: string }
        brand_id: { type: string }
        title: { type: string }
        body: { type: string }
        severity: { type: string }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time, nullable: true }
        created_by: { type: string, nullable: true }
        created_at: { type: string, format: date-time }

    Blackhole:
      type: object
      properties:
        id: { type: string }
        customer_id: { type: string }
        brand_id: { type: string }
        ip_address: { type: string }
        resource_type: { type: string }
        resource_id: { type: string }
        routeros_id: { type: string, nullable: true }
        routeros_comment: { type: string, nullable: true }
        reason: { type: string, nullable: true }
        is_active: { type: boolean }
        activated_at: { type: string, format: date-time }
        deactivated_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    CreditNote:
      type: object
      properties:
        id: { type: string }
        brand_id: { type: string }
        customer_id: { type: string }
        number: { type: string }
        invoice_id: { type: string, nullable: true }
        reason: { type: string }
        subtotal: { type: number }
        tax: { type: number }
        total: { type: number }
        currency: { type: string }
        refund_method: { type: string }
        status: { type: string }
        created_at: { type: string, format: date-time }

    Quote:
      type: object
      properties:
        id: { type: string }
        brand_id: { type: string }
        customer_id: { type: string, nullable: true }
        number: { type: string }
        status: { type: string }
        subtotal: { type: number }
        tax: { type: number }
        total: { type: number }
        currency: { type: string }
        line_items: { type: array, items: { type: object } }
        notes: { type: string, nullable: true }
        valid_until: { type: string, format: date-time, nullable: true }
        accepted_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    Order:
      type: object
      description: A customer order. Cents fields are internal; dollar fields returned. Attribution fields optional.
      properties:
        id: { type: string }
        brand_id: { type: string }
        customer_id: { type: string }
        customer_email: { type: string }
        number: { type: string }
        status: { type: string }
        fraud_score: { type: number, nullable: true }
        fraud_status: { type: string, nullable: true }
        ip_address: { type: string, nullable: true }
        promo_code_id: { type: string, nullable: true }
        invoice_id: { type: string, nullable: true }
        notes: { type: string, nullable: true }
        line_items: { type: array, items: { type: object } }
        subtotal: { type: number }
        tax: { type: number }
        total: { type: number }
        currency: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Article:
      type: object
      properties:
        id: { type: string }
        brand_id: { type: string, nullable: true }
        category_id: { type: string, nullable: true }
        title: { type: string }
        slug: { type: string }
        content: { type: string }
        excerpt: { type: string }
        seo_title: { type: string }
        sort_order: { type: integer }
        is_published: { type: boolean }
        view_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        published_at: { type: string, format: date-time, nullable: true }
        category_name: { type: string }
        category_slug: { type: string }

    Category:
      type: object
      properties:
        id: { type: string }
        brand_id: { type: string, nullable: true }
        name: { type: string }
        slug: { type: string }
        description: { type: string }
        icon: { type: string }
        sort_order: { type: integer }
        is_active: { type: boolean }
        article_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Feedback:
      type: object
      properties:
        id: { type: string }
        brand_id: { type: string }
        customer_id: { type: string }
        trigger_type: { type: string }
        trigger_resource_type: { type: string }
        trigger_resource_id: { type: string }
        rating: { type: integer, nullable: true }
        comment: { type: string }
        would_recommend: { type: boolean, nullable: true }
        requested_at: { type: string, format: date-time }
        responded_at: { type: string, format: date-time, nullable: true }
        dismissed_at: { type: string, format: date-time, nullable: true }
        allow_public: { type: boolean }
        public_display_name: { type: string }
        created_at: { type: string, format: date-time }

    NotificationPref:
      type: object
      properties:
        category: { type: string, enum: [billing, compute, security, support, announcements, marketing] }
        email: { type: boolean }
        in_app: { type: boolean }
        sms: { type: boolean }

    BrandConfig:
      type: object
      description: Public brand config (theme, analytics, flags; no secrets). Optional keys appear per brand.
      properties:
        brand_name: { type: string }
        supported_currencies:
          type: array
          items: { type: string }
          description: Billing currency set (account/invoice denominations). Always present.
        default_currency:
          type: string
          description: Brand fallback billing currency when the customer country has no mapping. Always present.
        analytics:
          type: object
          properties:
            gtm_container_id: { type: string }
            ga4_measurement_id: { type: string }
            x_pixel_id: { type: string }
            tawk_property_id: { type: string }
            ahrefs_key: { type: string }
        theme: { type: object, additionalProperties: true }
        feature_flags: { type: object, additionalProperties: { type: boolean } }
        object_storage:
          type: object
          description: Present only when the brand offers object storage. location_id is the only location a bucket may be created in; region is the SigV4 signing region.
          properties:
            region: { type: string }
            location_id: { type: string }
        external_urls: { type: object, additionalProperties: true }
        support_email: { type: string }
        recaptcha_enabled: { type: boolean }
        recaptcha_site_key: { type: string }
        payment_gateways: { type: array, items: { type: string } }
        mobile_money_countries:
          type: array
          items: { type: string }
          description: "ISO alpha-2 countries where Mobile Money is offered (the ACTIVE pawaPay corridors, single-sourced from the backend). Present only when the brand has mobile money enabled."
        flutterwave_countries:
          type: array
          items: { type: string }
          description: "ISO alpha-2 account countries where the Flutterwave local-payment rail is offered (country-driven, convert-first). Present only when the brand has Flutterwave enabled."
        startbutton_countries:
          type: array
          items: { type: string }
          description: "ISO alpha-2 account countries where the Startbutton local-payment rail is offered (country-driven, convert-first). Sorted. This is the proven corridor map NARROWED by the brand allowlist, so during a phased rollout it may be a strict subset. Present only when the brand has Startbutton enabled with a valid mode."
        bachs_countries:
          type: array
          items: { type: string }
          description: "ISO alpha-2 account countries where Bachs is offered on the dashboard (wallet top-up and invoice payment). NG when a Naira corridor is on, the single-country mobile money countries (GH, KE, TZ, UG, RW, MW, ZM) whose corridor is on, plus CFA countries on the brand allowlist whose mobile money corridor is on. An empty allowlist publishes no CFA country. Present only when the brand's Bachs config is usable."
        bachs_corridors:
          type: array
          items: { type: string }
          description: "Bachs payment methods this brand is offering, from NGN_CARD, NGN_BANK_TRANSFER, MOMO_XOF, MOMO_XAF, MOMO_GHS, MOMO_KES, MOMO_TZS, MOMO_UGX, MOMO_RWF, MOMO_MWK and MOMO_ZMW. Present only when the brand's Bachs config is usable."
        bachs_methods:
          type: object
          additionalProperties:
            type: array
            items: { type: string }
          description: "Each country in bachs_countries mapped to the Bachs methods offered there, default first (NG: NGN_CARD before NGN_BANK_TRANSFER). Send one as bachs_method on /billing/checkout; a MOMO_ method charges in the currency it names. Present only when the brand's Bachs config is usable."
        mobile_money_limits:
          type: object
          additionalProperties:
            type: object
            properties:
              min: { type: number }
              max: { type: number }
          description: >-
            Deposit window per Mobile Money corridor, keyed by ISO alpha-2 COUNTRY,
            in MAJOR units of that country's local charge currency. Keyed by country
            and not currency because the window belongs to the country's providers:
            CG, CM and GA all charge XAF and cap at 2,000,000, 1,000,000 and 500,000.
            Convert the order total to the local currency before comparing — this is
            NOT a limit on the billing amount. Present only when Mobile Money is enabled.
        startbutton_limits:
          type: object
          additionalProperties:
            type: object
            properties:
              min: { type: number }
              max: { type: number }
          description: >-
            Collection window per Startbutton corridor, keyed by CURRENCY (not country,
            unlike mobile_money_limits: Startbutton routes on currency alone, so one
            window serves every country on a corridor), in MAJOR units. Intended for the
            ORDER surface, which must decline to OFFER the rail above the cap: its
            refusal otherwise lands after the order and promo redemption exist and is
            idempotency-cached for 24h. Present only when Startbutton is enabled.
        limits:
          type: object
          description: "Display constants matching backend enforcement (single-sourced)."
          properties:
            max_snapshots_per_vm: { type: integer }
            backup_price_percent: { type: number }
        extra_ipv4_monthly_price_cents:
          type: object
          additionalProperties: { type: integer }
          description: "Monthly price (cents, keyed by currency) per additional IPv4 beyond a VM first included address. Present only when the brand offers it."
        routed_ipv6_monthly_price_cents:
          type: object
          additionalProperties: { type: integer }
          description: "Monthly price (cents, keyed by currency) per routed IPv6 /64 delegation. Present only when the brand offers it."
        block_storage_price_per_gb_month_cents:
          type: object
          additionalProperties: { type: integer }
          description: "Block storage (NVMe volume) list price per GB per month, whole cents keyed by currency (USD 15 = 0.15 a month). The rows the metering bills from. Present only when the brand has priced block storage."
        paypal_vault_supported: { type: boolean }
        stripe_public_key: { type: string }
        stripe_adaptive_pricing_countries: { type: array, items: { type: string } }
        detected_country: { type: string, description: "GeoIP ISO country, when resolvable" }
