openapi: 3.1.0
info:
  title: Impreza Host Public API
  description: |
    Official REST API for Impreza Host clients. Manage domains, hosting, VPS, email services, and place orders programmatically.

    ## Official SDKs & CLI

    Don't want to write HTTP calls yourself? Use our open-source **DevKit** — production-ready clients that wrap every endpoint below:

    - **Python SDK** — `pip install impreza-sdk` *(sync + async, typed with Pydantic v2)*
    - **Python CLI** — `pip install impreza-cli` *(rich terminal UI, Tor-aware)*
    - **Go CLI** — single static binary, no runtime required → [GitHub Releases](https://github.com/imprezahost/impreza-devkit/releases)

    Source code, recipes, and the webhook AsyncAPI spec live at
    **[github.com/imprezahost/impreza-devkit](https://github.com/imprezahost/impreza-devkit)** (MIT-licensed).

    ## Companion event API

    Push notifications are documented separately in
    [`asyncapi.yaml`](https://github.com/imprezahost/impreza-devkit/blob/master/openapi/asyncapi.yaml)
    (AsyncAPI 3.0). Subscribe via `POST /v1/webhooks` here; receive events on
    your URL as documented there.

    ## Authentication

    All requests require two headers:
    - `X-API-Key` — Your API key (starts with `imp_`)
    - `X-API-Secret` — Your API secret (shown once when generated)

    Generate keys in your [Impreza Account → API Management](https://portal.imprezahost.com/imprezaapi.php).

    ## IP Whitelist

    Your request IP must be whitelisted before making API calls. Add IPs in the API Management page.

    ## Rate Limiting

    Default: **60 requests per minute** per API key. Response headers:
    - `X-RateLimit-Limit` — Max requests per minute
    - `X-RateLimit-Remaining` — Remaining requests in current window
    - `X-RateLimit-Reset` — Unix timestamp when the window resets

    ## Destructive Operations

    Irreversible actions (reinstall, terminate, rollback) require `"confirm": true` in the request body.

    ## Errors

    All errors follow a consistent format:
    ```json
    {
      "success": false,
      "error": {
        "code": "ERROR_CODE",
        "message": "Human-readable description"
      },
      "meta": { "request_id": "req_abc123", "timestamp": "2026-03-30T14:00:00Z" }
    }
    ```
  version: 1.0.0
  contact:
    name: Impreza Host Support
    url: https://imprezahost.com
  license:
    name: Proprietary

servers:
  - url: https://api.imprezahost.com/v1
    description: Production

security:
  - ApiKey: []
    ApiSecret: []

tags:
  - name: Account
    description: Client account information and services
  - name: Products
    description: Product catalog with pricing (VPS, hosting, domains)
  - name: Orders
    description: Create orders and upgrades (paid from balance)
  - name: Invoices
    description: List, view, and pay invoices from account balance
  - name: Services
    description: Service management (cancellation)
  - name: Domains
    description: Domain pricing, registration, transfer, DNS, nameservers, locks
  - name: Hosting
    description: cPanel/WHM hosting management
  - name: Titan Email
    description: Titan Email service management
  - name: Google Workspace
    description: Google Workspace management
  - name: VPS Proxmox
    description: Proxmox VPS management (31+ operations)
  - name: VPS Cloud
    description: Cloud VPS management (27+ operations)
  - name: Dedicated Servers
    description: |
      Dedicated server management. Operations are gated by per-service
      capabilities: a feature is only available if the service advertises it
      in `GET /dedicated/{serviceId}/capabilities`. Calling an endpoint whose
      capability is not on that list returns `404 NOT_SUPPORTED`.
  - name: Platform
    description: |
      Impreza Platform — agent-managed app deployments. Bootstrap an
      external server (or use an Impreza-provisioned one), browse the
      curated app catalog, deploy catalog or custom apps, and manage
      their lifecycle (restart, change domain, add Tor mirror,
      uninstall, tail logs).

      Custom deploys accept four source modes auto-detected from which
      body fields are populated: `image` (public registry reference),
      `dockerfile` via `context_id` (upload a gzip tarball first), or
      via `git_url` (public https git repo, shallow-cloned at deploy
      time), or `manifest` (full docker-compose YAML, treated like a
      catalog app).

      For git-backed custom deploys, the
      `/platform/deployments/custom/{id}/git-webhook/*` endpoints
      install a GitHub push webhook so every commit to the watched
      branch auto-redeploys the app — no second API call needed.
  - name: Webhooks
    description: Subscribe to events delivered as HMAC-signed POSTs

components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: Your API key (e.g. `imp_a1b2c3d4...`)
    ApiSecret:
      type: apiKey
      in: header
      name: X-API-Secret
      description: Your API secret

  schemas:
    SuccessResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
        meta:
          $ref: '#/components/schemas/Meta'

    ErrorResponse:
      type: object
      properties:
        success:
          type: boolean
          example: false
        error:
          type: object
          properties:
            code:
              type: string
              example: INVALID_REQUEST
            message:
              type: string
              example: "Field 'hostname' is required."
            details:
              type: object
        meta:
          $ref: '#/components/schemas/Meta'

    Meta:
      type: object
      properties:
        request_id:
          type: string
          example: req_a1b2c3d4e5f6g7h8
        timestamp:
          type: string
          format: date-time
          example: "2026-03-30T14:05:00Z"

    AccountInfo:
      type: object
      properties:
        id:
          type: integer
          example: 1234
        first_name:
          type: string
          example: John
        last_name:
          type: string
          example: Doe
        company:
          type: string
          example: ACME Corp
        email:
          type: string
          example: john@example.com
        balance:
          type: number
          format: float
          example: 150.00
        currency:
          type: string
          example: USD
        registered_at:
          type: string
          example: "2024-01-15"

    Service:
      type: object
      properties:
        id:
          type: integer
          example: 567
        domain:
          type: string
          example: example.com
        status:
          type: string
          enum: [Active, Suspended, Terminated, Pending, Cancelled]
          example: Active
        product:
          type: string
          example: VPS Plan 2
        product_group:
          type: string
          example: VPS Hosting
        billing_cycle:
          type: string
          example: monthly
        amount:
          type: number
          example: 15.00
        dedicated_ip:
          type: string
          example: 185.100.86.42
        registered_at:
          type: string
          example: "2024-06-01"
        next_due:
          type: string
          example: "2026-04-01"
        vps_backend:
          type: [string, "null"]
          enum: [proxmox, cloud, null]
          description: |
            Stable backend identifier for VPS services, exposed so SDKs can
            smart-dispatch between `/vps/proxmox/...` and `/vps/cloud/...`
            without parsing the human-readable `product_group`. `null` for
            non-VPS services or when the underlying server module is not
            recognized.
          example: proxmox

    Order:
      type: object
      properties:
        id:
          type: integer
          example: 890
        order_number:
          type: string
          example: "4829174"
        date:
          type: string
          example: "2026-03-30"
        amount:
          type: number
          example: 15.00
        invoice_id:
          type: integer
          example: 2345
        status:
          type: string
          example: Active
        payment_method:
          type: string
          example: mailin

    DnsRecord:
      type: object
      properties:
        type:
          type: string
          enum: [A, AAAA, CNAME, MX, TXT, NS, SRV]
          example: A
        host:
          type: string
          example: "@"
        value:
          type: string
          example: "185.100.86.42"
        ttl:
          type: integer
          example: 14400
        priority:
          type: [integer, "null"]
          example: null

    VpsStatus:
      type: object
      properties:
        power_state:
          type: string
          example: running
        cpu_usage:
          type: number
          example: 12.5
        memory_used:
          type: integer
          example: 1073741824
        memory_total:
          type: integer
          example: 4294967296
        uptime:
          type: integer
          description: Uptime in seconds
          example: 864000

  responses:
    Unauthorized:
      description: Invalid or missing API credentials
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            error:
              code: UNAUTHORIZED
              message: Invalid API key.
            meta:
              request_id: req_abc123
              timestamp: "2026-03-30T14:05:00Z"

    Forbidden:
      description: IP not whitelisted or resource not owned
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    RateLimited:
      description: Rate limit exceeded
      headers:
        Retry-After:
          schema:
            type: integer
            example: 60
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            error:
              code: RATE_LIMITED
              message: "Rate limit exceeded. Maximum 60 requests per minute."

    UpstreamError:
      description: Error from upstream provider API
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    BadRequest:
      description: Validation failed on the request body or query parameters
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            error:
              code: INVALID_REQUEST
              message: "Field 'amount' must be a positive number."

    Conflict:
      description: Request would violate a unique constraint or state precondition
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            error:
              code: CONFLICT
              message: "Resource is in a state that does not allow this operation."

    InternalError:
      description: Unexpected server-side error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            error:
              code: INTERNAL_ERROR
              message: "An unexpected error occurred. Please retry; if it persists, contact support."

    InsufficientCredit:
      description: Action requires more account balance than available
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            error:
              code: INSUFFICIENT_CREDIT
              message: "Insufficient account balance. Top up with `impreza account topup`."

paths:
  # ═══════════════════════════════════════════════════════
  # ACCOUNT
  # ═══════════════════════════════════════════════════════

  /account:
    get:
      tags: [Account]
      summary: Get account information
      description: Returns client profile, balance, and currency.
      responses:
        '200':
          description: Account info
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - properties:
                      data:
                        $ref: '#/components/schemas/AccountInfo'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /account/services:
    get:
      tags: [Account]
      summary: List all services
      description: Returns all services (hosting, VPS, domains) for the authenticated client.
      parameters:
        - name: status
          in: query
          description: Filter by status
          schema:
            type: string
            enum: [Active, Suspended, Terminated, Pending, Cancelled]
      responses:
        '200':
          description: Service list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - properties:
                      data:
                        type: object
                        properties:
                          services:
                            type: array
                            items:
                              $ref: '#/components/schemas/Service'
                          total:
                            type: integer

  /account/services/{id}:
    get:
      tags: [Account]
      summary: Get service details
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Service details including server info and nameservers
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /account/topup:
    post:
      tags: [Account]
      summary: Create a crypto top-up invoice
      description: |
        Generates an `AddFunds` invoice routed to the `btcpayinline`
        gateway. Open `payment_url` to complete payment with Bitcoin (BTC),
        Monero (XMR), TRX, or USDT-TRC20. Once the gateway confirms payment,
        the client account balance is credited automatically.

        Use `GET /account/topup/{invoice_id}` to poll status.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: number
                  format: float
                  minimum: 1
                  maximum: 10000
                  description: Amount in the client's account currency.
                  example: 50.00
                method:
                  type: string
                  enum: [btc, xmr, trx, usdt, usdt_trc20]
                  description: |
                    Optional preferred payment method. Forwarded to the gateway
                    as a hint; the gateway may still display alternatives if the
                    preferred method is unavailable.
                  example: xmr
      responses:
        '201':
          description: Top-up invoice created successfully
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          invoice_id:
                            type: integer
                            example: 12345
                          amount:
                            type: number
                            format: float
                            example: 50.00
                          currency:
                            type: string
                            example: USD
                          method:
                            type: [string, "null"]
                            example: xmr
                          status:
                            type: string
                            enum: [pending, paid, cancelled, refunded]
                            example: pending
                          payment_url:
                            type: string
                            format: uri
                            example: https://portal.imprezahost.com/viewinvoice.php?id=12345&method=xmr
                          expires_at:
                            type: string
                            format: date-time
                            example: '2026-05-08T16:30:00Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalError'

  /account/topup/{invoice_id}:
    get:
      tags: [Account]
      summary: Get top-up invoice status
      description: |
        Poll this endpoint to check if a top-up invoice has been paid.
        Returns 404 if the invoice is not an `AddFunds` invoice or does
        not belong to the authenticated client.
      parameters:
        - name: invoice_id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Top-up invoice status
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          invoice_id:
                            type: integer
                            example: 12345
                          amount:
                            type: number
                            format: float
                            example: 50.00
                          currency:
                            type: string
                            example: USD
                          method:
                            type: [string, "null"]
                            example: xmr
                          status:
                            type: string
                            enum: [pending, paid, cancelled, refunded]
                            example: paid
                          paid_at:
                            type: [string, "null"]
                            format: date-time
                            example: '2026-05-08T15:42:00Z'
                          balance_after:
                            type: number
                            format: float
                            description: Present only when status is `paid`.
                            example: 95.32
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /account/api-keys/self:
    get:
      tags: [Account]
      summary: Identity and IP whitelist of the API key making this request
      description: |
        Returns metadata about the API key that authenticated this call. The
        full `api_key` value is never returned — only the public `prefix`
        (the first 12 chars). The `request_ip` is the IP that the server
        observed for this call (useful when debugging why a different IP than
        expected is being whitelisted).

        Used by clients implementing `impreza key whoami`.
      responses:
        '200':
          description: Key identity
          content:
            application/json:
              example:
                success: true
                data:
                  id: 42
                  client_id: 123
                  prefix: 'imp_a1b2c3d4'
                  label: 'CI server'
                  status: 'active'
                  last_used_at: '2026-05-08T15:42:00Z'
                  created_at: '2026-04-01T10:00:00Z'
                  rate_limit_per_minute: 60
                  request_ip: '203.0.113.50'
                  ip_whitelist:
                    - id: 1
                      ip_address: '203.0.113.50'
                      label: 'CI'
                      created_at: '2026-04-01T10:01:00Z'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ═══════════════════════════════════════════════════════
  # PRODUCTS
  # ═══════════════════════════════════════════════════════

  /products:
    get:
      tags: [Products]
      summary: List all available products
      description: |
        Returns all available products grouped by category with pricing per billing cycle.
        Prices are shown in the authenticated client's currency.
      parameters:
        - name: group
          in: query
          description: Filter by product group name (e.g. "VPS", "Hosting")
          schema:
            type: string
        - name: type
          in: query
          description: Filter by product type
          schema:
            type: string
            enum: [hostingaccount, reselleraccount, server, other]
      responses:
        '200':
          description: Product catalog
          content:
            application/json:
              example:
                success: true
                data:
                  products:
                    - id: 15
                      name: "VPS Plan 2"
                      description: "4 vCPU, 8GB RAM, 100GB SSD"
                      type: server
                      group: "VPS Hosting"
                      group_id: 3
                      currency: USD
                      pricing:
                        monthly: { price: 15.00, setup_fee: 0.00 }
                        quarterly: { price: 40.00, setup_fee: 0.00 }
                        annually: { price: 144.00, setup_fee: 0.00 }
                  total: 12
                  currency: USD

  /products/groups:
    get:
      tags: [Products]
      summary: List product groups
      description: Returns all product groups with the number of available products in each.
      responses:
        '200':
          description: Product groups
          content:
            application/json:
              example:
                success: true
                data:
                  groups:
                    - { id: 1, name: "Shared Hosting", product_count: 4 }
                    - { id: 3, name: "VPS Hosting", product_count: 6 }
                    - { id: 5, name: "Dedicated Servers", product_count: 3 }
                  total: 3

  /products/{id}:
    get:
      tags: [Products]
      summary: Get product details
      description: |
        Returns a single product with full pricing per billing cycle, setup fees,
        custom fields, and configurable options (e.g. OS template) required for ordering.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Product details with pricing, custom fields, and configurable options
          content:
            application/json:
              example:
                success: true
                data:
                  id: 15
                  name: "VPS Plan 2"
                  description: "4 vCPU, 8GB RAM, 100GB SSD"
                  type: server
                  group: "VPS Hosting"
                  group_id: 3
                  currency: USD
                  pricing:
                    monthly: { price: 15.00, setup_fee: 0.00 }
                    annually: { price: 144.00, setup_fee: 0.00 }
                  custom_fields:
                    - { id: 1, name: "Hostname", type: "text", required: true }
                  config_options:
                    - id: 5
                      name: "Operating System"
                      type: 1
                      options:
                        - { id: 10, name: "Ubuntu 22.04", pricing: { monthly: 0.00 } }
                        - { id: 11, name: "Debian 12", pricing: { monthly: 0.00 } }
                        - { id: 12, name: "Windows Server 2022", pricing: { monthly: 5.00 } }
        '404':
          $ref: '#/components/responses/NotFound'

  # ═══════════════════════════════════════════════════════
  # ORDERS
  # ═══════════════════════════════════════════════════════

  /orders:
    get:
      tags: [Orders]
      summary: List orders
      responses:
        '200':
          description: Order list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - properties:
                      data:
                        type: object
                        properties:
                          orders:
                            type: array
                            items:
                              $ref: '#/components/schemas/Order'
                          total:
                            type: integer

    post:
      tags: [Orders]
      summary: Create a new order
      description: |
        Creates a new order and pays from account credit balance.
        If the balance is insufficient, returns `409 INSUFFICIENT_BALANCE`.

        Use `GET /products/{id}` to discover available `config_options` (e.g. OS template,
        number of email accounts) and `custom_fields` for the product before placing an order.

        ### Examples

        **VPS with OS template:**
        ```json
        {
          "product_id": 15,
          "billing_cycle": "monthly",
          "hostname": "my-vps",
          "config_options": { "5": "10" }
        }
        ```

        **Titan Email with 5 accounts:**
        ```json
        {
          "product_id": 20,
          "billing_cycle": "annually",
          "domain": "example.com",
          "config_options": { "8": "5" }
        }
        ```

        **Google Workspace with 3 seats:**
        ```json
        {
          "product_id": 25,
          "billing_cycle": "annually",
          "domain": "example.com",
          "config_options": { "12": "3" }
        }
        ```
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id, billing_cycle]
              properties:
                product_id:
                  type: integer
                  description: Product ID from Impreza Account
                  example: 15
                billing_cycle:
                  type: string
                  enum: [monthly, quarterly, semiannually, annually, biennially, triennially]
                  example: monthly
                domain:
                  type: string
                  description: Domain for the service (if applicable)
                  example: myserver.com
                hostname:
                  type: string
                  description: Hostname for VPS services
                  example: my-vps
                config_options:
                  type: object
                  description: |
                    Configurable options (e.g. OS template). Object keys are option IDs,
                    values are the selected sub-option IDs. Get available options from `GET /products/{id}`.
                  example: { "5": "10" }
                custom_fields:
                  type: object
                  description: |
                    Custom field values. Object keys are field IDs, values are the field values.
                    Get available fields from `GET /products/{id}`.
                  example: { "1": "myserver.example.com" }
      responses:
        '201':
          description: Order created successfully
          content:
            application/json:
              example:
                success: true
                data:
                  order_id: 890
                  invoice_id: 2345
                  product: "VPS Plan 2"
                  amount: 15.00
                  status: Active
                  message: "Order created and paid from account balance."
        '400':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Insufficient balance
          content:
            application/json:
              example:
                success: false
                error:
                  code: INSUFFICIENT_BALANCE
                  message: "Insufficient balance. Required: $15.00, Available: $3.50"

  /orders/{id}:
    get:
      tags: [Orders]
      summary: Get order details
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Order details with items
        '404':
          $ref: '#/components/responses/NotFound'

  /orders/{id}/upgrade:
    post:
      tags: [Orders]
      summary: Upgrade a service
      description: Creates an upgrade order paid from account credit balance.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [service_id, new_product_id, billing_cycle]
              properties:
                service_id:
                  type: integer
                  example: 567
                new_product_id:
                  type: integer
                  example: 25
                billing_cycle:
                  type: string
                  example: monthly
      responses:
        '201':
          description: Upgrade created
        '409':
          description: Insufficient balance

  # ═══════════════════════════════════════════════════════
  # INVOICES
  # ═══════════════════════════════════════════════════════

  /invoices:
    get:
      tags: [Invoices]
      summary: List invoices
      description: |
        Returns all invoices for the authenticated client.
        Use `?status=Unpaid` to filter only unpaid invoices (e.g. pending renewals).
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [Paid, Unpaid, Cancelled, Refunded]
          description: Filter by invoice status
      responses:
        '200':
          description: Invoice list
          content:
            application/json:
              example:
                success: true
                data:
                  invoices:
                    - id: 2345
                      invoice_num: "2345"
                      date: "2026-03-01"
                      due_date: "2026-03-15"
                      date_paid: null
                      subtotal: 17.00
                      credit: 0.00
                      tax: 0.00
                      total: 17.00
                      status: Unpaid
                      payment_method: banktransfer
                  total: 1

  /invoices/{id}:
    get:
      tags: [Invoices]
      summary: Get invoice details
      description: Returns invoice details including line items and transaction history.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Invoice details with items and transactions
          content:
            application/json:
              example:
                success: true
                data:
                  id: 2345
                  invoice_num: "2345"
                  date: "2026-03-01"
                  due_date: "2026-03-15"
                  total: 17.00
                  status: Unpaid
                  items:
                    - id: 1
                      type: Hosting
                      description: "VPS Plan 2 - Monthly (03/15/2026 - 04/14/2026)"
                      amount: 17.00
                      taxed: false
                  transactions: []
        '404':
          $ref: '#/components/responses/NotFound'

  /invoices/{id}/pay:
    post:
      tags: [Invoices]
      summary: Pay invoice from balance
      description: |
        Pays an unpaid invoice using the client's account credit balance.
        This is the recommended way to handle renewals — the system generates
        renewal invoices automatically, and the client pays them via this endpoint.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Invoice paid successfully
          content:
            application/json:
              example:
                success: true
                data:
                  invoice_id: 2345
                  amount: 17.00
                  currency: USD
                  message: "Invoice paid from account balance."
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Already paid or insufficient balance
          content:
            application/json:
              examples:
                already_paid:
                  value:
                    success: false
                    error:
                      code: ALREADY_PAID
                      message: "This invoice is already paid."
                insufficient_balance:
                  value:
                    success: false
                    error:
                      code: INSUFFICIENT_BALANCE
                      message: "Insufficient balance. Required: USD 17.00, Available: USD 5.00"

  # ═══════════════════════════════════════════════════════
  # SERVICES
  # ═══════════════════════════════════════════════════════

  /services/{id}/cancel:
    post:
      tags: [Services]
      summary: Request service cancellation
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                  enum: ["Immediate", "End of Billing Period"]
                  example: "End of Billing Period"
                reason:
                  type: string
                  example: "No longer needed"
      responses:
        '200':
          description: Cancellation submitted
          content:
            application/json:
              example:
                success: true
                data:
                  service_id: 567
                  cancel_type: "End of Billing Period"
                  message: "Cancellation request submitted successfully."

  # ═══════════════════════════════════════════════════════
  # WEBHOOKS
  # ═══════════════════════════════════════════════════════

  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook subscriptions
      description: Returns every webhook subscription owned by the authenticated client.
      responses:
        '200':
          description: List of subscriptions
          content:
            application/json:
              example:
                success: true
                data:
                  webhooks:
                    - id: 1
                      url: https://example.com/hooks/impreza
                      events: [vps.power_state_changed, invoice.paid]
                      description: production handler
                      is_active: true
                      last_delivery_at: '2026-05-08T15:42:00Z'
                      last_delivery_status: 200
                      created_at: '2026-05-01T10:00:00Z'
                  total: 1
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      description: |
        Creates a subscription that receives `application/json` `POST` requests
        for the chosen event types. Each delivery includes:

        - `X-Impreza-Signature: sha256=<hex>` — HMAC-SHA256 of the raw body
          using the subscription's `secret`.
        - `X-Impreza-Event` — concrete event type
        - `X-Impreza-Event-Id` — unique event identifier (`evt_...`)
        - `X-Impreza-Delivery-Attempt` — delivery attempt counter

        The `secret` is returned **once** in the response. Store it securely;
        it cannot be retrieved later. Use `POST /webhooks/{id}/rotate-secret`
        to generate a new one.

        Subscriptions support wildcards: `vps.*` matches every VPS event,
        `*` matches everything.

        On creation, a `webhook.test` probe event is enqueued for delivery
        so you can verify your endpoint works.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  maxLength: 500
                  example: https://example.com/hooks/impreza
                events:
                  type: array
                  minItems: 1
                  items:
                    type: string
                  example: [vps.*, invoice.paid, topup.paid]
                description:
                  type: string
                  maxLength: 200
                  example: production handler
      responses:
        '201':
          description: Subscription created — secret returned ONCE
          content:
            application/json:
              example:
                success: true
                data:
                  id: 1
                  url: https://example.com/hooks/impreza
                  events: [vps.*, invoice.paid, topup.paid]
                  description: production handler
                  is_active: true
                  last_delivery_at: null
                  last_delivery_status: null
                  created_at: '2026-05-08T16:00:00Z'
                  secret: a8f3...bcd9
                  secret_warning: 'Save this secret — it cannot be retrieved later. Rotate via POST /webhooks/{id}/rotate-secret.'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          description: Subscription limit reached
          content:
            application/json:
              example:
                success: false
                error: { code: LIMIT_REACHED, message: 'Maximum of 10 webhook subscriptions per client.' }

  /webhooks/event-types:
    get:
      tags: [Webhooks]
      summary: List supported event types
      responses:
        '200':
          description: Event catalog with wildcard help
          content:
            application/json:
              example:
                success: true
                data:
                  event_types:
                    - webhook.test
                    - topup.paid
                    - invoice.created
                    - invoice.paid
                    - order.created
                    - service.activated
                    - service.suspended
                    - service.cancelled
                    - domain.registered
                    - domain.transferred
                    - domain.expiring_soon
                    - domain.expired
                    - vps.power_state_changed
                    - vps.backup_completed
                    - vps.snapshot_created
                    - vps.reinstall_completed
                    - dedicated.power.changed
                    - dedicated.rdns.updated
                    - dedicated.rdns.reset
                    - dedicated.reinstall.queued
                    - dedicated.kvm.enabled
                    - dedicated.kvm.disabled
                    - dedicated.firewall.updated
                    - deployment.created
                    - deployment.installed
                    - deployment.failed
                    - deployment.domain_changed
                    - deployment.uninstalled
                    - deployment.git_redeployed
                  wildcards:
                    "*": Receive every event (catch-all).
                    "vps.*": Receive every event whose type starts with vps.
                    "domain.*": Receive every event whose type starts with domain.
                    "dedicated.*": Receive every event whose type starts with dedicated.
                    "deployment.*": Receive every event whose type starts with deployment.

  /webhooks/{id}:
    get:
      tags: [Webhooks]
      summary: Get a webhook subscription
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Subscription details
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Webhooks]
      summary: Update a webhook subscription
      description: Any subset of fields may be supplied; missing fields are unchanged.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url:
                  type: string
                  format: uri
                  maxLength: 500
                events:
                  type: array
                  items:
                    type: string
                description:
                  type: string
                  maxLength: 200
                is_active:
                  type: boolean
      responses:
        '200':
          description: Subscription updated
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Webhooks]
      summary: Delete a webhook subscription
      description: |
        Removes the subscription and discards any pending (undelivered)
        deliveries in the queue. Already delivered events keep their history.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Subscription deleted
          content:
            application/json:
              example:
                success: true
                data: { id: 1, deleted: true }
        '404':
          $ref: '#/components/responses/NotFound'

  /webhooks/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      summary: Rotate the HMAC secret
      description: |
        Generates a fresh signing secret. The previous one becomes invalid
        immediately. New secret is returned ONCE.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: New secret returned
          content:
            application/json:
              example:
                success: true
                data:
                  id: 1
                  secret: f1a2...e90b
                  secret_warning: 'Save this secret — it cannot be retrieved later. The previous secret is now invalid.'
        '404':
          $ref: '#/components/responses/NotFound'

  /webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: List recent delivery attempts
      description: Returns the last 100 delivery attempts for this subscription.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Delivery history
          content:
            application/json:
              example:
                success: true
                data:
                  deliveries:
                    - id: 42
                      event_type: vps.power_state_changed
                      event_id: evt_a1b2c3
                      attempts: 1
                      next_attempt_at: '2026-05-08T16:01:00Z'
                      last_attempted_at: '2026-05-08T16:00:30Z'
                      last_response_code: 200
                      last_error: null
                      delivered: true
                      delivered_at: '2026-05-08T16:00:30Z'
                      created_at: '2026-05-08T16:00:00Z'
                  total: 1
        '404':
          $ref: '#/components/responses/NotFound'

  # ═══════════════════════════════════════════════════════
  # DOMAINS
  # ═══════════════════════════════════════════════════════

  /domains/pricing:
    get:
      tags: [Domains]
      summary: List TLD pricing
      description: |
        Returns all available TLDs with registration and renewal prices per year.
        Prices are shown in the authenticated client's currency.
      parameters:
        - name: tld
          in: query
          description: "Filter by TLD (comma-separated, e.g. `.com,.net,.io`)"
          schema:
            type: string
      responses:
        '200':
          description: TLD pricing list
          content:
            application/json:
              example:
                success: true
                data:
                  tlds:
                    - tld: ".com"
                      register: { "1": 12.99, "2": 25.98, "3": 38.97 }
                      renew: { "1": 14.99, "2": 29.98 }
                      currency: USD
                      min_years: 1
                      cheapest: 12.99
                    - tld: ".net"
                      register: { "1": 14.99 }
                      renew: { "1": 16.99 }
                      currency: USD
                      min_years: 1
                      cheapest: 14.99
                  total: 45
                  currency: USD

  /domains/register:
    post:
      tags: [Domains]
      summary: Register a new domain
      description: |
        Checks domain availability, verifies pricing and client balance,
        then creates a registration order and deducts from credit balance.
        Free DNS management is automatically activated upon successful registration.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain, years]
              properties:
                domain:
                  type: string
                  example: "mynewdomain.com"
                years:
                  type: integer
                  minimum: 1
                  maximum: 10
                  example: 1
                nameservers:
                  type: array
                  items:
                    type: string
                  example: ["ns1.imprezahost.com", "ns2.imprezahost.com"]
      responses:
        '201':
          description: Domain registered
          content:
            application/json:
              example:
                success: true
                data:
                  order_id: 1234
                  invoice_id: 5678
                  domain: "mynewdomain.com"
                  years: 1
                  amount: 12.99
                  currency: USD
                  status: Registered
                  message: "Domain registration order created and paid from account balance."
        '409':
          description: Domain not available or insufficient balance
          content:
            application/json:
              examples:
                unavailable:
                  value:
                    success: false
                    error:
                      code: DOMAIN_UNAVAILABLE
                      message: "Domain mynewdomain.com is not available for registration."
                no_balance:
                  value:
                    success: false
                    error:
                      code: INSUFFICIENT_BALANCE
                      message: "Insufficient balance. Required: USD 12.99, Available: USD 0.00"

  /domains/transfer:
    post:
      tags: [Domains]
      summary: Transfer a domain
      description: |
        Transfer a domain from another registrar. Requires the EPP/auth code.
        Creates an order and deducts from credit balance.
        Free DNS management is automatically activated upon successful transfer.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain, epp_code]
              properties:
                domain:
                  type: string
                  example: "existing-domain.com"
                epp_code:
                  type: string
                  description: EPP/Authorization code from current registrar
                  example: "abc123xyz"
                years:
                  type: integer
                  minimum: 1
                  default: 1
      responses:
        '201':
          description: Transfer order created
          content:
            application/json:
              example:
                success: true
                data:
                  order_id: 1235
                  invoice_id: 5679
                  domain: "existing-domain.com"
                  years: 1
                  amount: 12.99
                  currency: USD
                  message: "Domain transfer order created and paid from account balance."
        '409':
          description: Insufficient balance

  /domains/check:
    get:
      tags: [Domains]
      summary: Check domain availability
      description: Check availability of up to 10 domains at once.
      parameters:
        - name: domains
          in: query
          required: true
          description: Comma-separated list of domains
          schema:
            type: string
          example: "example.com,example.net,example.io"
      responses:
        '200':
          description: Availability results
          content:
            application/json:
              example:
                success: true
                data:
                  availability:
                    example.com: false
                    example.net: true
                    example.io: true

  /domains/{domain}:
    get:
      tags: [Domains]
      summary: Get domain details
      description: Full domain info including status, expiry, nameservers, lock status, EPP code, WHOIS privacy.
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
          example: example.com
      responses:
        '200':
          description: Full domain details
        '403':
          $ref: '#/components/responses/Forbidden'

  /domains/{domain}/raa-verify:
    post:
      tags: [Domains]
      summary: Resend RAA verification email
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Verification email resent

  /domains/{domain}/nameservers:
    put:
      tags: [Domains]
      summary: Update nameservers
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nameservers]
              properties:
                nameservers:
                  type: array
                  items:
                    type: string
                  minItems: 2
                  example: ["ns1.imprezahost.com", "ns2.imprezahost.com"]
      responses:
        '200':
          description: Nameservers updated

  /domains/{domain}/lock:
    post:
      tags: [Domains]
      summary: Enable transfer lock
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Transfer lock enabled

    delete:
      tags: [Domains]
      summary: Disable transfer lock
      description: Disables transfer lock and returns the EPP/auth code.
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Transfer lock disabled
          content:
            application/json:
              example:
                success: true
                data:
                  epp_code: "abc123xyz"

  /domains/{domain}/dns/activate:
    post:
      tags: [Domains]
      summary: Activate DNS management
      description: Must be done before adding/editing DNS records.
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: DNS management activated

  /domains/{domain}/dns:
    get:
      tags: [Domains]
      summary: List all DNS records
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: DNS records
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - properties:
                      data:
                        type: object
                        properties:
                          records:
                            type: array
                            items:
                              $ref: '#/components/schemas/DnsRecord'

    post:
      tags: [Domains]
      summary: Add a DNS record
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, host, value]
              properties:
                type:
                  type: string
                  enum: [A, AAAA, CNAME, MX, TXT, NS, SRV]
                host:
                  type: string
                  example: "@"
                value:
                  type: string
                  example: "185.100.86.42"
                ttl:
                  type: integer
                  default: 14400
                priority:
                  type: integer
                  description: Required for MX records
      responses:
        '201':
          description: Record created

    put:
      tags: [Domains]
      summary: Update a DNS record
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, host, old_value, new_value]
              properties:
                type:
                  type: string
                  enum: [A, AAAA, CNAME, MX, TXT, NS, SRV]
                host:
                  type: string
                old_value:
                  type: string
                new_value:
                  type: string
                ttl:
                  type: integer
                  default: 14400
                priority:
                  type: integer
      responses:
        '200':
          description: Record updated

    delete:
      tags: [Domains]
      summary: Delete a DNS record
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, host, value]
              properties:
                type:
                  type: string
                host:
                  type: string
                value:
                  type: string
      responses:
        '200':
          description: Record deleted

  /domains/{domain}/gdpr-auth:
    post:
      tags: [Domains]
      summary: Resend GDPR authorization email
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: GDPR auth email resent

  /domains/{domain}/transfer-approval:
    post:
      tags: [Domains]
      summary: Resend transfer approval email
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Transfer approval email resent

  /domains/{domain}/id-protection:
    post:
      tags: [Domains]
      summary: Purchase ID Protection
      description: |
        Activates ID Protection (WHOIS Privacy) for a registered domain.
        Creates an invoice and pays from account credit balance.
        Price is based on the TLD addon pricing configured in the system.
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '201':
          description: ID Protection activated
          content:
            application/json:
              example:
                success: true
                data:
                  domain: example.com
                  domain_id: 456
                  invoice_id: 7890
                  amount: 2.99
                  currency: USD
                  message: "ID Protection activated and paid from account balance."
        '409':
          description: Already active or insufficient balance
          content:
            application/json:
              examples:
                already_active:
                  value:
                    success: false
                    error:
                      code: ALREADY_ACTIVE
                      message: "ID Protection is already active for this domain."
                insufficient_balance:
                  value:
                    success: false
                    error:
                      code: INSUFFICIENT_BALANCE
                      message: "Insufficient balance. Required: USD 2.99, Available: USD 0.50"

  # ═══════════════════════════════════════════════════════
  # HOSTING
  # ═══════════════════════════════════════════════════════

  /hosting/{serviceId}:
    get:
      tags: [Hosting]
      summary: Get hosting account info
      description: Returns cPanel account summary (IP, plan, disk, bandwidth, status).
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Hosting account info
        '403':
          $ref: '#/components/responses/Forbidden'

  /hosting/{serviceId}/nameservers:
    get:
      tags: [Hosting]
      summary: Get server nameservers
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Nameservers
          content:
            application/json:
              example:
                success: true
                data:
                  nameservers: ["ns1.imprezahost.com", "ns2.imprezahost.com"]

  /hosting/{serviceId}/autossl:
    post:
      tags: [Hosting]
      summary: Trigger AutoSSL check
      description: Initiates free SSL certificate installation via AutoSSL.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: AutoSSL check initiated

  # ═══════════════════════════════════════════════════════
  # TITAN EMAIL
  # To order Titan Email, use POST /orders with the Titan product ID,
  # the domain, and config_options for the number of email accounts.
  # ═══════════════════════════════════════════════════════

  /email/titan/{domain}:
    get:
      tags: [Titan Email]
      summary: Get Titan Email details
      description: Returns order details including accounts used/available, plan, expiry.
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Titan Email details

  /email/titan/{domain}/dns:
    get:
      tags: [Titan Email]
      summary: Get required DNS records
      description: Returns MX, SPF, DKIM, and autodiscover records needed for Titan Email.
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Required DNS records

  /email/titan/{domain}/sso:
    get:
      tags: [Titan Email]
      summary: Get SSO URL
      description: Returns a single-use SSO URL to the Titan Email management panel (valid 48h).
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: SSO URL
          content:
            application/json:
              example:
                success: true
                data:
                  sso_url: "https://titan.email/sso/..."
                  iframe_url: "https://titan.email/iframe/..."

  # ═══════════════════════════════════════════════════════
  # GOOGLE WORKSPACE
  # To order Google Workspace, use POST /orders with the Google Workspace
  # product ID, the domain, and config_options for the number of seats.
  # ═══════════════════════════════════════════════════════

  /email/google/{domain}:
    get:
      tags: [Google Workspace]
      summary: Get Google Workspace details
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Workspace details

  /email/google/dns:
    get:
      tags: [Google Workspace]
      summary: Get required DNS records
      description: Returns MX records needed for Google Workspace.
      responses:
        '200':
          description: Required MX records

  /email/google/{domain}/admin:
    post:
      tags: [Google Workspace]
      summary: Setup admin account
      description: Creates the initial admin user for a new Google Workspace order.
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email_address, first_name, last_name, alternate_email, name, company, zip]
              properties:
                email_address:
                  type: string
                  example: admin@example.com
                first_name:
                  type: string
                last_name:
                  type: string
                alternate_email:
                  type: string
                name:
                  type: string
                company:
                  type: string
                zip:
                  type: string
      responses:
        '201':
          description: Admin account created

  # ═══════════════════════════════════════════════════════
  # VPS PROXMOX
  # ═══════════════════════════════════════════════════════

  /vps/proxmox/{serviceId}:
    get:
      tags: [VPS Proxmox]
      summary: Get VM info
      description: Returns hostname, OS, cores, RAM, disk, IPs, location.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: VM information
  /vps/proxmox/{serviceId}/cancel:
    post:
      tags: [VPS Proxmox]
      summary: Request service cancellation
      description: |
        Submits a cancellation request via Impreza Account. The VM is **not** destroyed immediately —
        cancellation is processed by Impreza Account according to the chosen type.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                  enum: [Immediate, End of Billing Period]
                  example: End of Billing Period
                reason:
                  type: string
                  description: Optional cancellation reason
                  example: No longer needed
      responses:
        '200':
          description: Cancellation request submitted
          content:
            application/json:
              example:
                success: true
                data:
                  service_id: 123
                  cancel_type: "End of Billing Period"
                  message: "Cancellation request submitted successfully."

  /vps/proxmox/{serviceId}/status:
    get:
      tags: [VPS Proxmox]
      summary: Get VM status
      description: Power state, CPU%, memory, uptime.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: VM status
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - properties:
                      data:
                        $ref: '#/components/schemas/VpsStatus'

  /vps/proxmox/{serviceId}/resources:
    get:
      tags: [VPS Proxmox]
      summary: Get resource usage
      description: CPU, memory, disk I/O, network statistics.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Resource usage

  /vps/proxmox/{serviceId}/config:
    get:
      tags: [VPS Proxmox]
      summary: Get VM configuration
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Full Proxmox config

  /vps/proxmox/{serviceId}/pending:
    get:
      tags: [VPS Proxmox]
      summary: Get pending changes
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Config changes awaiting reboot

  /vps/proxmox/{serviceId}/ips:
    get:
      tags: [VPS Proxmox]
      summary: List assigned IPs
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: IP list

  /vps/proxmox/{serviceId}/backups:
    get:
      tags: [VPS Proxmox]
      summary: List backups
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Backup list
    post:
      tags: [VPS Proxmox]
      summary: Create backup
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '201':
          description: Backup created

  /vps/proxmox/{serviceId}/backups/{id}/restore:
    post:
      tags: [VPS Proxmox]
      summary: Restore backup
      description: Async operation — VM is stopped during restore.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Restore initiated

  /vps/proxmox/{serviceId}/backups/{id}:
    delete:
      tags: [VPS Proxmox]
      summary: Delete backup
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Backup deleted

  /vps/proxmox/{serviceId}/backup-schedules:
    get:
      tags: [VPS Proxmox]
      summary: List backup schedules
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Schedule list
    post:
      tags: [VPS Proxmox]
      summary: Create backup schedule
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                dow:
                  type: string
                  description: Day of week (mon,tue,wed...)
                hour:
                  type: integer
                minute:
                  type: integer
                mode:
                  type: string
                  enum: [snapshot, suspend, stop]
                compress:
                  type: string
                  enum: [none, lzo, gzip, zstd]
      responses:
        '201':
          description: Schedule created

  /vps/proxmox/{serviceId}/backup-schedules/{id}:
    delete:
      tags: [VPS Proxmox]
      summary: Delete backup schedule
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Schedule deleted

  /vps/proxmox/{serviceId}/templates:
    get:
      tags: [VPS Proxmox]
      summary: List available OS templates
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Template list

  /vps/proxmox/{serviceId}/snapshots:
    get:
      tags: [VPS Proxmox]
      summary: List snapshots
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Snapshot list
    post:
      tags: [VPS Proxmox]
      summary: Create snapshot
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  example: before-upgrade
                description:
                  type: string
      responses:
        '201':
          description: Snapshot created

  /vps/proxmox/{serviceId}/snapshots/{name}:
    delete:
      tags: [VPS Proxmox]
      summary: Delete snapshot
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
        - name: name
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Snapshot deleted

  /vps/proxmox/{serviceId}/snapshots/{name}/rollback:
    post:
      tags: [VPS Proxmox]
      summary: Rollback to snapshot
      description: "**VM is stopped during rollback.** Requires `confirm: true`."
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
        - name: name
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm]
              properties:
                confirm:
                  type: boolean
      responses:
        '200':
          description: Rollback initiated

  /vps/proxmox/{serviceId}/available-ips:
    get:
      tags: [VPS Proxmox]
      summary: Get available IPs
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Available IP count

  /vps/proxmox/{serviceId}/locations:
    get:
      tags: [VPS Proxmox]
      summary: Get migration locations
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Available locations

  /vps/proxmox/{serviceId}/console:
    get:
      tags: [VPS Proxmox]
      summary: Get noVNC console URL
      description: Returns a browser console URL valid for approximately 2 hours.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Console URL

  /vps/proxmox/{serviceId}/console/ssh:
    post:
      tags: [VPS Proxmox]
      summary: Get SSH console
      description: Returns a WebSocket URL and encrypted token for SSH access.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password:
                  type: string
      responses:
        '200':
          description: SSH console credentials

  /vps/proxmox/{serviceId}/start:
    post:
      tags: [VPS Proxmox]
      summary: Start VM
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: VM started

  /vps/proxmox/{serviceId}/shutdown:
    post:
      tags: [VPS Proxmox]
      summary: Shutdown VM (graceful)
      description: Sends ACPI shutdown signal.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Shutdown initiated

  /vps/proxmox/{serviceId}/reboot:
    post:
      tags: [VPS Proxmox]
      summary: Reboot VM
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Reboot initiated

  /vps/proxmox/{serviceId}/stop:
    post:
      tags: [VPS Proxmox]
      summary: Force stop VM
      description: "**Warning:** Risk of data corruption. Use shutdown for graceful stop."
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: VM force-stopped

  /vps/proxmox/{serviceId}/hostname:
    put:
      tags: [VPS Proxmox]
      summary: Change hostname
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hostname]
              properties:
                hostname:
                  type: string
                  example: my-server.example.com
      responses:
        '200':
          description: Hostname changed

  /vps/proxmox/{serviceId}/password:
    put:
      tags: [VPS Proxmox]
      summary: Change root password
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password:
                  type: string
                  minLength: 8
      responses:
        '200':
          description: Password changed

  /vps/proxmox/{serviceId}/reinstall:
    post:
      tags: [VPS Proxmox]
      summary: Reinstall OS
      description: "**Wipes all data.** Async operation. Requires `confirm: true`."
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template_id, password, confirm]
              properties:
                template_id:
                  type: integer
                  description: Template ID from /templates endpoint
                password:
                  type: string
                  minLength: 8
                confirm:
                  type: boolean
                  example: true
      responses:
        '200':
          description: Reinstall initiated (returns operation UUID)

  /vps/proxmox/{serviceId}/network/reconfigure:
    post:
      tags: [VPS Proxmox]
      summary: Reconfigure network
      description: Applies network config via QEMU Guest Agent. May require reboot.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Network reconfigured

  /vps/proxmox/{serviceId}/migrate:
    post:
      tags: [VPS Proxmox]
      summary: Migrate VM
      description: "Async operation. Requires `confirm: true`."
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm]
              properties:
                server_id:
                  type: integer
                group_id:
                  type: integer
                confirm:
                  type: boolean
      responses:
        '200':
          description: Migration initiated

  /vps/proxmox/{serviceId}/operations:
    get:
      tags: [VPS Proxmox]
      summary: List active operations
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Active operations list

  /vps/proxmox/{serviceId}/operations/{uuid}:
    get:
      tags: [VPS Proxmox]
      summary: Get operation status
      parameters:
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
        - name: uuid
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Operation status

  # ═══════════════════════════════════════════════════════
  # VPS CLOUD (summary — same pattern)
  # ═══════════════════════════════════════════════════════

  /vps/cloud/{vmId}:
    get:
      tags: [VPS Cloud]
      summary: Get VM info
      parameters: &opVmIdParam
        - name: vmId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: VM info
  /vps/cloud/{vmId}/cancel:
    post:
      tags: [VPS Cloud]
      summary: Request service cancellation
      description: |
        Submits a cancellation request via Impreza Account. The VM is **not** destroyed immediately —
        cancellation is processed by Impreza Account according to the chosen type.
      parameters: *opVmIdParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                  enum: [Immediate, End of Billing Period]
                  example: End of Billing Period
                reason:
                  type: string
                  description: Optional cancellation reason
                  example: No longer needed
      responses:
        '200':
          description: Cancellation request submitted
          content:
            application/json:
              example:
                success: true
                data:
                  vm_id: 456
                  service_id: 123
                  cancel_type: "End of Billing Period"
                  message: "Cancellation request submitted successfully."

  /vps/cloud/templates:
    get:
      tags: [VPS Cloud]
      summary: List available OS templates
      responses:
        '200':
          description: Templates

  /vps/cloud/locations:
    get:
      tags: [VPS Cloud]
      summary: List datacenter locations
      responses:
        '200':
          description: Locations

  /vps/cloud/sizes:
    get:
      tags: [VPS Cloud]
      summary: List VM plan sizes
      responses:
        '200':
          description: Plan sizes

  /vps/cloud/images:
    get:
      tags: [VPS Cloud]
      summary: List saved images (backups)
      responses:
        '200':
          description: Image list

  /vps/cloud/{vmId}/boot:
    post:
      tags: [VPS Cloud]
      summary: Boot VM
      parameters: *opVmIdParam
      responses:
        '200':
          description: VM booted

  /vps/cloud/{vmId}/shutdown:
    post:
      tags: [VPS Cloud]
      summary: Shutdown VM (graceful)
      parameters: *opVmIdParam
      responses:
        '200':
          description: Shutdown initiated

  /vps/cloud/{vmId}/reboot:
    post:
      tags: [VPS Cloud]
      summary: Reboot VM
      parameters: *opVmIdParam
      responses:
        '200':
          description: Reboot initiated

  /vps/cloud/{vmId}/poweroff:
    post:
      tags: [VPS Cloud]
      summary: Force power off
      parameters: *opVmIdParam
      responses:
        '200':
          description: VM powered off

  /vps/cloud/{vmId}/hostname:
    put:
      tags: [VPS Cloud]
      summary: Change hostname
      parameters: *opVmIdParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hostname]
              properties:
                hostname:
                  type: string
      responses:
        '200':
          description: Hostname changed

  /vps/cloud/{vmId}/password:
    put:
      tags: [VPS Cloud]
      summary: Change password
      parameters: *opVmIdParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password:
                  type: string
                  minLength: 8
      responses:
        '200':
          description: Password changed

  /vps/cloud/{vmId}/reinstall:
    post:
      tags: [VPS Cloud]
      summary: Reinstall OS
      description: "**Wipes all data.** Requires `confirm: true`."
      parameters: *opVmIdParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template, confirm]
              properties:
                template:
                  type: string
                  example: ubuntu-22.04
                ssh_keys:
                  type: string
                confirm:
                  type: boolean
      responses:
        '200':
          description: Reinstall initiated

  /vps/cloud/{vmId}/resize:
    post:
      tags: [VPS Cloud]
      summary: Resize VM
      description: Requires reboot after resize.
      parameters: *opVmIdParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [instance_size]
              properties:
                instance_size:
                  type: string
      responses:
        '200':
          description: Resize initiated

  /vps/cloud/{vmId}/vnc:
    get:
      tags: [VPS Cloud]
      summary: Get VNC credentials
      parameters: *opVmIdParam
      responses:
        '200':
          description: VNC IP, port, password

  /vps/cloud/{vmId}/images:
    post:
      tags: [VPS Cloud]
      summary: Create image (backup)
      parameters: *opVmIdParam
      responses:
        '201':
          description: Image created

  /vps/cloud/{vmId}/images/{id}/restore:
    post:
      tags: [VPS Cloud]
      summary: Restore image
      parameters:
        - name: vmId
          in: path
          required: true
          schema:
            type: integer
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Restore initiated

  /vps/cloud/images/{id}:
    delete:
      tags: [VPS Cloud]
      summary: Delete image
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Image deleted

  /vps/cloud/rdns/{ip}:
    get:
      tags: [VPS Cloud]
      summary: Get rDNS (PTR record)
      parameters:
        - name: ip
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: rDNS record
    put:
      tags: [VPS Cloud]
      summary: Set/update rDNS
      parameters:
        - name: ip
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain:
                  type: string
      responses:
        '200':
          description: rDNS updated
    delete:
      tags: [VPS Cloud]
      summary: Delete rDNS
      parameters:
        - name: ip
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: rDNS deleted

  /vps/cloud/{vmId}/rescue:
    post:
      tags: [VPS Cloud]
      summary: Enable rescue mode
      description: Reboot required to enter rescue mode after enabling.
      parameters: *opVmIdParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password:
                  type: string
      responses:
        '200':
          description: Rescue mode enabled
    delete:
      tags: [VPS Cloud]
      summary: Disable rescue mode
      parameters: *opVmIdParam
      responses:
        '200':
          description: Rescue mode disabled

  /vps/cloud/ssh-keys:
    get:
      tags: [VPS Cloud]
      summary: List SSH keys
      responses:
        '200':
          description: SSH key list

  /vps/cloud/{vmId}/ssh-keys:
    post:
      tags: [VPS Cloud]
      summary: Assign SSH keys to VM
      parameters: *opVmIdParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ssh_keys]
              properties:
                ssh_keys:
                  type: string
      responses:
        '200':
          description: SSH keys assigned

  /vps/cloud/{vmId}/ipv6:
    post:
      tags: [VPS Cloud]
      summary: Enable IPv6
      parameters: *opVmIdParam
      responses:
        '200':
          description: IPv6 enabled

  /vps/cloud/{vmId}/boot-order:
    put:
      tags: [VPS Cloud]
      summary: Change boot order
      parameters: *opVmIdParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bootorder]
              properties:
                bootorder:
                  type: string
                  enum: [cda, dca]
      responses:
        '200':
          description: Boot order changed

  /vps/cloud/{vmId}/iso/mount:
    post:
      tags: [VPS Cloud]
      summary: Mount ISO
      parameters: *opVmIdParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [iso]
              properties:
                iso:
                  type: string
      responses:
        '200':
          description: ISO mounted

  /vps/cloud/{vmId}/iso:
    delete:
      tags: [VPS Cloud]
      summary: Unmount ISO
      parameters: *opVmIdParam
      responses:
        '200':
          description: ISO unmounted

  /vps/cloud/{vmId}/vnc-password:
    put:
      tags: [VPS Cloud]
      summary: Change VNC password
      parameters: *opVmIdParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password:
                  type: string
      responses:
        '200':
          description: VNC password changed

  # ═══════════════════════════════════════════════════════
  # DEDICATED SERVERS
  # ═══════════════════════════════════════════════════════

  /dedicated:
    get:
      tags: [Dedicated Servers]
      summary: List dedicated services for the authenticated client
      description: |
        Returns one entry per dedicated server the client owns. Each entry
        carries a `capabilities` array — use it to decide which actions to
        surface in your UI/automation.
      responses:
        '200':
          description: List of dedicated services
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            service_id: { type: integer, example: 17042 }
                            domain:     { type: string, example: "server-01.cliente.com" }
                            ip:         { type: string, example: "192.0.2.10" }
                            status:     { type: string, example: "Active" }
                            capabilities:
                              type: array
                              items: { type: string, example: "power.on" }

  /dedicated/{serviceId}:
    get:
      tags: [Dedicated Servers]
      summary: Full service details (hardware, IPs, status, capabilities)
      parameters: &dedServiceIdParam
        - name: serviceId
          in: path
          required: true
          schema:
            type: integer
          description: WHMCS service id (tblhosting.id)
      responses:
        '200':
          description: Service details
        '404':
          description: Service not found or not owned by the client
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /dedicated/{serviceId}/capabilities:
    get:
      tags: [Dedicated Servers]
      summary: Capability list for the service
      description: |
        Possible capability strings: `details`, `status`, `power.on`, `power.off`,
        `power.reboot`, `ips.list`, `rdns`, `rdns.reset_all`, `os.list`,
        `reinstall`, `kvm`, `kvm.toggle`, `firewall`, `bandwidth`, `vpn`.
      parameters: *dedServiceIdParam
      responses:
        '200':
          description: Capabilities
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          capabilities:
                            type: array
                            items: { type: string }

  /dedicated/{serviceId}/status:
    get:
      tags: [Dedicated Servers]
      summary: Current power / provisioning state
      parameters: *dedServiceIdParam
      responses:
        '200': { description: Status }

  /dedicated/{serviceId}/ips:
    get:
      tags: [Dedicated Servers]
      summary: List assigned IPs with current PTR
      parameters: *dedServiceIdParam
      responses:
        '200': { description: IPs }

  /dedicated/{serviceId}/os-images:
    get:
      tags: [Dedicated Servers]
      summary: OS images available for reinstall
      parameters: *dedServiceIdParam
      responses:
        '200': { description: OS images }
        '404':
          description: Capability not supported by this service

  /dedicated/{serviceId}/kvm:
    get:
      tags: [Dedicated Servers]
      summary: Current KVM / IPMI access info
      description: |
        The exact payload shape depends on the service — some return a
        ready-to-open URL, others a credential bundle, others a console
        link. When VPN access is required to reach the BMC, the response
        will instruct the caller to pull credentials from
        `/dedicated/{id}/vpn` (capability-gated).
      parameters: *dedServiceIdParam
      responses:
        '200': { description: KVM info }
    delete:
      tags: [Dedicated Servers]
      summary: Disable KVM session
      parameters: *dedServiceIdParam
      responses:
        '200': { description: KVM disabled }

  /dedicated/{serviceId}/kvm/enable:
    post:
      tags: [Dedicated Servers]
      summary: Enable KVM session
      description: |
        Some backends bind the session to the calling IP. The public API
        injects the end-user's real IP (`X-Forwarded-For`/`X-Real-IP`/REMOTE_ADDR)
        automatically — clients don't need to pass anything.
      parameters: *dedServiceIdParam
      responses:
        '200': { description: KVM enabled }

  /dedicated/{serviceId}/firewall:
    get:
      tags: [Dedicated Servers]
      summary: DDoS firewall state
      description: Requires the `firewall` capability — otherwise returns `404 NOT_SUPPORTED`.
      parameters: *dedServiceIdParam
      responses:
        '200': { description: Firewall state }
        '404':
          description: This service does not support firewall (returns `NOT_SUPPORTED`)
    put:
      tags: [Dedicated Servers]
      summary: Update firewall state / sensitivity for an IP
      description: Requires the `firewall` capability — otherwise returns `404 NOT_SUPPORTED`.
      parameters: *dedServiceIdParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ip]
              properties:
                ip:
                  type: string
                  format: ipv4
                  example: "192.0.2.10"
                state:
                  type: string
                  nullable: true
                  enum: [always_on, redirect_on_attack, null]
                  description: Set to `null` to leave unchanged
                sensitivity:
                  type: string
                  nullable: true
                  enum: [low, normal, medium, high, null]
                  description: Set to `null` to leave unchanged
      responses:
        '200': { description: Firewall updated }
        '404':
          description: This service does not support firewall

  /dedicated/{serviceId}/firewall/logs:
    get:
      tags: [Dedicated Servers]
      summary: DDoS attack logs
      description: Requires the `firewall` capability.
      parameters: *dedServiceIdParam
      responses:
        '200': { description: DDoS logs }
        '404':
          description: This service does not support DDoS logs

  /dedicated/{serviceId}/bandwidth:
    get:
      tags: [Dedicated Servers]
      summary: Bandwidth graph (PNG base64)
      description: Requires the `bandwidth` capability.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema: { type: integer }
        - in: query
          name: type
          schema:
            type: string
            enum: [port_bits, port_upkts, port_percent, port_errors, port_pktsize, port_discards]
            default: port_bits
        - in: query
          name: scale
          schema:
            type: string
            enum: [day, week, month]
            default: month
      responses:
        '200': { description: Bandwidth graph (PNG base64) }
        '404':
          description: This service does not support bandwidth

  /dedicated/{serviceId}/vpn:
    get:
      tags: [Dedicated Servers]
      summary: VPN credentials
      description: |
        Returns the rotating VPN password and OpenVPN client download URL.
        Required to reach the BMC on services that publish their management
        interface behind VPN. Requires the `vpn` capability.
      parameters: *dedServiceIdParam
      responses:
        '200': { description: VPN credentials }
        '404':
          description: This service does not expose VPN credentials

  /dedicated/{serviceId}/start:
    post:
      tags: [Dedicated Servers]
      summary: Power on
      description: Requires the `power.on` capability.
      parameters: *dedServiceIdParam
      responses:
        '200': { description: Server starting }
        '404':
          description: This service does not support power control

  /dedicated/{serviceId}/shutdown:
    post:
      tags: [Dedicated Servers]
      summary: Graceful shutdown
      description: Requires the `power.off` capability.
      parameters: *dedServiceIdParam
      responses:
        '200': { description: Server shutting down }

  /dedicated/{serviceId}/reboot:
    post:
      tags: [Dedicated Servers]
      summary: Reboot
      description: Requires the `power.reboot` capability.
      parameters: *dedServiceIdParam
      responses:
        '200': { description: Server rebooting }

  /dedicated/{serviceId}/ips/{ip}/rdns:
    put:
      tags: [Dedicated Servers]
      summary: Set PTR for a specific IP
      description: |
        Applied synchronously on most services. On services where the PTR
        cannot be updated through an automated path, the response is
        `{status: "queued", message: "..."}` and our team applies the
        change manually within a few hours.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema: { type: integer }
        - in: path
          name: ip
          required: true
          schema: { type: string, format: ipv4 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hostname]
              properties:
                hostname:
                  type: string
                  example: "server.cliente.com"
      responses:
        '200': { description: rDNS updated (sync or queued) }

  /dedicated/{serviceId}/ips/rdns/reset:
    post:
      tags: [Dedicated Servers]
      summary: Reset PTR to the Impreza default on every IP
      description: Default pattern is `<ip-with-dashes>.impreza.host`.
      parameters: *dedServiceIdParam
      responses:
        '200': { description: PTR reset (sync or queued) }

  /dedicated/{serviceId}/reinstall:
    post:
      tags: [Dedicated Servers]
      summary: Reinstall OS (destructive — wipes all data)
      description: |
        Destructive — requires **both** confirmations:
          - body field `"confirm": true`
          - header `X-Impreza-Confirm: WIPE`

        On services that support a synchronous reinstall path, the response
        returns the generated root password in `data.root_password` (masked
        in the audit log). On services where the reinstall must be applied
        manually, the response is `{status: "queued", message: "..."}` and
        our team executes the reinstall within a few hours.
      parameters:
        - name: serviceId
          in: path
          required: true
          schema: { type: integer }
        - in: header
          name: X-Impreza-Confirm
          required: true
          schema:
            type: string
            enum: [WIPE]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [os_id, password, confirm]
              properties:
                os_id:
                  type: string
                  description: OS id from `GET /os-images`
                  example: "1"
                os_label:
                  type: string
                  description: Optional — auto-resolved from os_id if absent
                  example: "Ubuntu 22.04"
                password:
                  type: string
                  minLength: 8
                  format: password
                  description: New root password (min 8 chars). Masked in audit log.
                confirm:
                  type: boolean
                  enum: [true]
                  description: Must be `true` to acknowledge data loss
      responses:
        '200':
          description: Reinstall accepted (sync result on services that support it, queued otherwise)
        '400':
          description: CONFIRMATION_REQUIRED when body confirm or header is missing

  # ═══════════════════════════════════════════════════════
  # PLATFORM — Servers & Apps
  # ═══════════════════════════════════════════════════════

  /platform/servers:
    get:
      tags: [Platform]
      summary: List your servers (agents)
      description: |
        Returns every agent owned by the authenticated client — both
        Impreza-provisioned VPSes (with `service_id` linking back to
        the underlying hosting service) and bring-your-own external
        boxes. Agents whose IP no longer maps to one of your active
        services are filtered out.
      responses:
        '200':
          description: Server list
          content:
            application/json:
              example:
                success: true
                data:
                  servers:
                    - agent_id: agt_4bcbffef132a2089
                      hostname: vps-frankfurt-01
                      origin: impreza-proxmox
                      service_id: 18281
                      status: online
                      version: "0-phase15-4df322c"
                      last_seen_at: "2026-05-27T22:20:46Z"
                      registered_at: "2026-05-27T00:56:28Z"
                  total: 1
                meta:
                  request_id: req_35159c1f1fe8a853
                  timestamp: "2026-05-27T22:20:47Z"
        '401':
          $ref: '#/components/responses/Unauthorized'

  /platform/servers/external/bootstrap:
    post:
      tags: [Platform]
      summary: Issue a one-time bootstrap token for a bring-your-own server
      description: |
        Generates a single-use token (~10 min TTL) plus the curl-pipe
        one-liner that installs the agent on a Linux host you own. The
        agent self-registers on first contact and joins your fleet.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  description: Optional friendly label for the agent (free-text, shown on My Apps panel).
                  example: home-office-nuc
      responses:
        '201':
          description: Bootstrap token issued
          content:
            application/json:
              example:
                success: true
                data:
                  bootstrap_token: "bts_8f4e2c0a9b1d3f7e..."
                  install_url: "https://raw.githubusercontent.com/imprezahost/agent-public/main/install.sh"
                  one_liner: "curl -fsSL https://raw.githubusercontent.com/imprezahost/agent-public/main/install.sh | IMPREZA_BOOTSTRAP=bts_8f4e2c0a9b1d3f7e... sh"
                  expires_at: "2026-05-27T22:30:47Z"
        '401':
          $ref: '#/components/responses/Unauthorized'

  /platform/apps:
    get:
      tags: [Platform]
      summary: Browse the curated app catalog
      description: |
        Lists every enabled app in the curated catalog (Vaultwarden,
        n8n, Nextcloud, Matrix, Gitea, SearXNG, Memos, Uptime Kuma,
        OpenClaw, Activepieces, …).
      parameters:
        - name: category
          in: query
          description: Filter by category (e.g. `security`, `productivity`).
          schema:
            type: string
        - name: search
          in: query
          description: Substring match across name, display_name, and description.
          schema:
            type: string
      responses:
        '200':
          description: App list
          content:
            application/json:
              example:
                success: true
                data:
                  apps:
                    - name: vaultwarden
                      display_name: Vaultwarden
                      category: security
                      tags: [password-manager, e2ee, self-hosted, bitwarden-compatible]
                      description: Self-hosted password manager compatible with all Bitwarden clients.
                      icon_url: /modules/addons/imprezaapi/assets/icons/vaultwarden.svg
                      latest_version: "1.32.0"
                      requirements: { ram_mb: 512, disk_gb: 2, cpu_cores: 1, ports: [80] }
                      supports: { onion: true, custom_domain: true, letsencrypt: true }
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'

  /platform/apps/{name}:
    get:
      tags: [Platform]
      summary: Get full app metadata + latest version manifest
      description: |
        Returns the app card data plus the latest version's full
        manifest (compose YAML, lifecycle hooks, declared vars). Useful
        before a deploy to inspect required vars or supported features.
      parameters:
        - name: name
          in: path
          required: true
          schema:
            type: string
          example: vaultwarden
      responses:
        '200':
          description: App details with manifest
        '404':
          $ref: '#/components/responses/NotFound'

  # ═══════════════════════════════════════════════════════
  # PLATFORM — Catalog Deployments
  # ═══════════════════════════════════════════════════════

  /platform/deployments:
    get:
      tags: [Platform]
      summary: List your catalog deployments
      description: |
        Returns catalog deployments owned by the authenticated client.
        Custom (non-catalog) deployments live under
        `/platform/deployments/custom`.
      parameters:
        - name: agent_id
          in: query
          description: Only return deployments running on this agent.
          schema:
            type: string
            example: agt_4bcbffef132a2089
        - name: status
          in: query
          description: Filter by deployment status.
          schema:
            type: string
            enum: [pending, installing, running, failed, uninstalling, uninstalled, updating]
      responses:
        '200':
          description: Deployment list
          content:
            application/json:
              example:
                success: true
                data:
                  deployments:
                    - id: dpl_a1b2c3d4e5f6g7h8
                      agent_id: agt_4bcbffef132a2089
                      app_name: vaultwarden
                      app_version: "1.32.0"
                      status: running
                      domain: vault.example.com
                      onion: null
                      vars: { SIGNUPS_ALLOWED: "true" }
                      last_health_at: "2026-05-27T22:20:46Z"
                      last_error: null
                      created_at: "2026-05-27T22:10:00Z"
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Platform]
      summary: Deploy a catalog app
      description: |
        Installs an app from the curated catalog on the target agent.
        Returns immediately with `status: pending`; poll
        `GET /platform/deployments/{id}` until it reaches `running`
        (typically 30 s – 3 min depending on the app).

        Domain handling — match the clientarea behavior across all
        callers (API, CLI, MCP):

        - **Leave `domain` empty** → server auto-generates
          `<app-slug>-<6hex>.imprezaapps.com`, creates the Cloudflare
          A record at the agent's public IP, and uses it as the
          deployment hostname. Most common path for new customers.
        - **Pass `domain` ending in `.imprezaapps.com`** → server
          ensures a Cloudflare A record exists for that exact name
          (idempotent — reuses if already there).
        - **Pass a domain you own** → passthrough; you handle DNS
          (point an A record at the agent's public IP before deploy
          so Let's Encrypt can issue).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [app_name, agent_id]
              properties:
                app_name:
                  type: string
                  description: Name of the catalog app (e.g. `vaultwarden`).
                  example: vaultwarden
                agent_id:
                  type: string
                  example: agt_4bcbffef132a2089
                app_version:
                  type: string
                  description: Specific version. Defaults to `latest_version` from the catalog.
                  example: "1.32.0"
                domain:
                  type: string
                  description: |
                    Public hostname the app will respond on (FQDN, no scheme).
                    Optional — leave empty to get an auto-generated
                    `<slug>-<6hex>.imprezaapps.com` subdomain. When set
                    to a `*.imprezaapps.com` name, the server ensures
                    the Cloudflare record exists. For customer-owned
                    apex domains, point the A record at the agent IP
                    before calling so Let's Encrypt can issue.

                    **Uniqueness:** `*.imprezaapps.com` is a shared
                    namespace — first-come-first-served across all
                    accounts. Picking a name already claimed by any
                    live deployment (yours or someone else's) returns
                    `400 INVALID_REQUEST` with a neutral
                    "already in use" message (no enumeration on other
                    accounts). Names are released when the deploy
                    fully uninstalls.
                  example: vault.example.com
                onion:
                  type: boolean
                  description: Also publish a Tor v3 hidden service alongside the clearnet URL.
                  default: false
                vars:
                  type: object
                  description: |
                    App-specific variables. The exact keys depend on the
                    manifest — call `GET /platform/apps/{name}` to see
                    declared `vars` with their `required` / `default` /
                    `generate` settings.
                  example: { signups_allowed: "true" }
      responses:
        '201':
          description: Deployment enqueued
          content:
            application/json:
              example:
                success: true
                data:
                  id: dpl_a1b2c3d4e5f6g7h8
                  agent_id: agt_4bcbffef132a2089
                  app_name: vaultwarden
                  app_version: "1.32.0"
                  status: pending
                  domain: vault.example.com
                  onion: null
                  vars: { DEPLOYMENT_ID: "dpl_a1b2c3d4e5f6g7h8", SIGNUPS_ALLOWED: "true", DOMAIN_URL: "https://vault.example.com" }
                  last_health_at: null
                  last_error: null
                  created_at: "2026-05-27T22:10:00Z"
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /platform/deployments/{id}:
    get:
      tags: [Platform]
      summary: Get a deployment (catalog or custom)
      description: |
        Returns the deployment row. The same URL works for both catalog
        and custom deployments — the server checks both tables and
        responds with the matching shape.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          example: dpl_a1b2c3d4e5f6g7h8
      responses:
        '200':
          description: Deployment found
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /platform/deployments/{id}/uninstall:
    post:
      tags: [Platform]
      summary: Uninstall a deployment
      description: |
        Enqueues an uninstall command for the agent. Idempotent — if the
        deployment is already in `uninstalling` or `uninstalled`, the
        endpoint returns 202 without queuing a second command.

        Works transparently for both catalog and custom deployments.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                purge_data:
                  type: boolean
                  default: false
                  description: When `true`, the agent also wipes the deployment's data volume (`docker compose down --volumes`).
                confirm:
                  type: boolean
                  description: Required by clients that want to be explicit; not enforced server-side.
      responses:
        '202':
          description: Uninstall enqueued
          content:
            application/json:
              example:
                success: true
                data:
                  command_id: cmd_350cba19c10a4927
                  deployment:
                    id: dpl_a1b2c3d4e5f6g7h8
                    status: uninstalling
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /platform/deployments/{id}/restart:
    post:
      tags: [Platform]
      summary: Restart a deployment's containers
      description: |
        Runs `docker compose restart` on the agent. Status transiently
        flips to `installing` while the containers come back up.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '202':
          description: Restart enqueued
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /platform/deployments/{id}/logs:
    post:
      tags: [Platform]
      summary: Tail container logs (synchronous)
      description: |
        Requests a chunk of container logs from the agent and waits up
        to ~25 s for the agent to ship them back. Returns the
        concatenated text plus a `final` flag indicating whether the
        agent finished delivering.

        For continuous tailing, poll this endpoint with
        `since_seconds: <previous_delta>`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                lines:
                  type: integer
                  minimum: 1
                  maximum: 5000
                  default: 200
                  description: How many trailing lines of container logs to ship.
                since_seconds:
                  type: integer
                  minimum: 0
                  default: 0
                  description: When > 0, only ship logs newer than this. Overrides `lines`.
      responses:
        '200':
          description: Logs (may be partial if `final` is false)
          content:
            application/json:
              example:
                success: true
                data:
                  deployment_id: dpl_a1b2c3d4e5f6g7h8
                  stream_id: stm_3f2a1b8c9d0e1f2a
                  command_id: cmd_8a7b6c5d4e3f2a1b
                  chunks: 4
                  final: true
                  logs: |
                    2026-05-27 22:20:47 [INFO]  starting service
                    2026-05-27 22:20:48 [INFO]  listening on :80
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /platform/deployments/{id}/domain:
    post:
      tags: [Platform]
      summary: Change a running deployment's domain
      description: |
        Re-routes a running deployment to a new hostname. The agent
        regenerates its Caddy fragment + reloads with zero downtime;
        the container and any Tor sidecar are untouched.

        DNS for the new hostname is the customer's responsibility —
        point an A record at the agent's public IP (or a CNAME at the
        old subdomain) before calling this.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain:
                  type: string
                  description: New FQDN (no scheme).
                  example: new.example.com
      responses:
        '202':
          description: Domain change enqueued
          content:
            application/json:
              example:
                success: true
                data:
                  command_id: cmd_8a7b6c5d4e3f2a1b
                  old_domain: vault.example.com
                  new_domain: new.example.com
                  deployment:
                    id: dpl_a1b2c3d4e5f6g7h8
                    status: running
                    domain: new.example.com
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /platform/deployments/{id}/onion/add:
    post:
      tags: [Platform]
      summary: Add a Tor `.onion` mirror to a running deployment
      description: |
        Provisions a new Tor v3 hidden service for a deployment that is
        currently running clearnet-only. The clearnet URL keeps
        working — the `.onion` is added alongside.

        Only available for apps whose manifest declares
        `supports.onion: true` (the clientarea hides the button
        otherwise; the API also enforces it).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '202':
          description: Onion provisioning enqueued. The address arrives via the agent's deploy-result and shows up on the deployment row a few seconds later.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Already has an onion, or not in running state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: App manifest does not declare supports.onion=true.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ═══════════════════════════════════════════════════════
  # PLATFORM — Custom Deployments
  # ═══════════════════════════════════════════════════════

  /platform/deployments/custom:
    get:
      tags: [Platform]
      summary: List your custom deployments
      description: |
        Returns custom (non-catalog) deployments — apps the customer
        shipped themselves as a public image, Dockerfile, git URL, or
        full manifest. Per-account isolated by design: one customer's
        custom apps never surface in another's listing.
      parameters:
        - name: agent_id
          in: query
          description: Only return custom deployments running on this agent.
          schema:
            type: string
      responses:
        '200':
          description: Custom deployment list
          content:
            application/json:
              example:
                success: true
                data:
                  deployments:
                    - id: dpl_d0fd487b19a32ac0
                      name: ui-smoke
                      mode: dockerfile
                      agent_id: agt_4bcbffef132a2089
                      status: running
                      domain: https://ui-smoke.imprezaapps.com
                      onion: null
                      image: ""
                      git_url: https://github.com/traefik/whoami
                      git_ref: master
                      cpus: 1
                      memory_mb: 512
                      vars:
                        CPUS: "1"
                        DOMAIN: "ui-smoke.imprezaapps.com"
                        HOST_PORT: "8080"
                        DOMAIN_URL: "https://ui-smoke.imprezaapps.com"
                        TARGET_PORT: "80"
                        DEPLOYMENT_ID: "dpl_d0fd487b19a32ac0"
                      created_at: "2026-05-27T22:46:13Z"
                      last_health_at: "2026-05-27T22:47:41Z"
                      last_error: null
                  total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Platform]
      summary: Deploy a custom (non-catalog) app
      description: |
        Creates a custom deployment. **Source mode is auto-detected**
        from which body fields are populated — pick exactly one:

        - **Image** — set `image` to a public registry reference. Agent
          does `docker pull` + `docker run`.

            ```json
            { "name": "my-nginx", "agent_id": "agt_...", "image": "nginx:alpine",
              "target_port": 80, "domain": "static.example.com" }
            ```

        - **Dockerfile via tarball** — first
          `POST /platform/deployments/custom/contexts` with a gzip
          tarball of your project, then reference the returned
          `context_id` here.

            ```json
            { "name": "my-app", "agent_id": "agt_...", "context_id": "ctx_...",
              "dockerfile_path": "Dockerfile", "target_port": 8080,
              "cpus": 1.0, "memory_mb": 512, "domain": "myapp.example.com" }
            ```

        - **Dockerfile via git URL** — point at a public https git repo;
          the agent shallow-clones at deploy time.

            ```json
            { "name": "my-app", "agent_id": "agt_...",
              "git_url": "https://github.com/you/your-repo", "git_ref": "main",
              "dockerfile_path": "Dockerfile", "target_port": 8080,
              "domain": "myapp.example.com" }
            ```

        - **Manifest** — full docker-compose YAML, treated like a
          catalog deploy. `cpus`/`memory_mb` are ignored in this mode —
          use compose's `cpus:` / `mem_limit:` per service.

            ```json
            { "name": "my-app", "agent_id": "agt_...",
              "manifest": { "runtime": { "type": "docker-compose", "compose_yaml": "..." } },
              "domain": "myapp.example.com" }
            ```

            **Routing contract** (manifest mode only): when your routes
            use the standard `{deployment_id}-<suffix>:<port>` upstream
            pattern, the matching service in `compose_yaml` MUST
            declare `container_name: ${DEPLOYMENT_ID}-<suffix>`.
            Without it, Docker Compose auto-generates names like
            `app-1` that the reverse proxy can't resolve, and every
            HTTPS request returns 502. The server rejects manifests
            that violate this contract with `400 INVALID_REQUEST` at
            create time. See the `llms-full.txt` doc for full examples.

        `name` is per-account unique (3–100 chars matching
        `^[a-z0-9][a-z0-9_-]{1,98}[a-z0-9]$`).

        For deploys created with `git_url`, see
        `/platform/deployments/custom/{id}/git-webhook/*` to wire up
        auto-redeploy on push.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, agent_id]
              properties:
                name:
                  type: string
                  description: Per-account unique name. 3–100 chars `[a-z0-9_-]`.
                  example: my-app
                agent_id:
                  type: string
                  example: agt_4bcbffef132a2089
                domain:
                  type: string
                  description: |
                    Public hostname (FQDN, no scheme). Optional —
                    leave empty for an auto-generated
                    `<name-slug>-<6hex>.imprezaapps.com` subdomain
                    (the server creates the Cloudflare record). Pass
                    a `*.imprezaapps.com` name and the server still
                    ensures the CF record. For customer-owned apex
                    domains, point the A record at the agent IP
                    before calling. Required only when `onion=false`
                    AND your operator hasn't configured an
                    imprezaapps apex.

                    **Uniqueness:** `*.imprezaapps.com` is a shared
                    namespace — first-come-first-served across all
                    accounts. Picking a name already claimed by any
                    live deployment returns `400 INVALID_REQUEST`
                    with "already in use". Names are released when
                    the deploy fully uninstalls.
                  example: myapp.example.com
                onion:
                  type: boolean
                  description: Publish a Tor v3 hidden service. Set `domain=""` for onion-only.
                  default: false
                cpus:
                  type: number
                  format: float
                  default: 1.0
                  description: Cgroup CPU ceiling. Ignored in `manifest` mode.
                memory_mb:
                  type: integer
                  default: 512
                  description: Cgroup memory ceiling in MB. Ignored in `manifest` mode.
                target_port:
                  type: integer
                  default: 80
                  description: Container port the reverse proxy forwards to.
                vars:
                  type: object
                  description: Extra env vars to drop into the container's `.env`.
                # Source modes — pick exactly one of the following families:
                image:
                  type: string
                  description: "Image mode: public registry reference like `ghcr.io/user/app:tag` or `nginx:alpine`."
                context_id:
                  type: string
                  description: "Dockerfile-tarball mode: id returned by `POST /platform/deployments/custom/contexts`."
                  example: ctx_a1b2c3d4e5f6
                git_url:
                  type: string
                  description: "Dockerfile-git mode: public https URL of a git repo."
                  example: https://github.com/you/your-repo
                git_ref:
                  type: string
                  description: "Branch / tag / commit to clone in git mode."
                  default: main
                dockerfile_path:
                  type: string
                  description: "Path to the Dockerfile inside the context / repo root."
                  default: Dockerfile
                manifest:
                  type: object
                  description: "Manifest mode: full docker-compose manifest object with `runtime.type: docker-compose` + `runtime.compose_yaml`."
      responses:
        '201':
          description: Custom deployment enqueued
          content:
            application/json:
              example:
                success: true
                data:
                  id: dpl_d0fd487b19a32ac0
                  name: ui-smoke
                  mode: dockerfile
                  agent_id: agt_4bcbffef132a2089
                  status: pending
                  domain: ui-smoke.imprezaapps.com
                  onion: null
                  image: ""
                  git_url: https://github.com/traefik/whoami
                  git_ref: master
                  cpus: 1
                  memory_mb: 512
                  vars: { CPUS: "1", DOMAIN: "ui-smoke.imprezaapps.com", HOST_PORT: "8080", MEMORY_MB: "512", DOMAIN_URL: "https://ui-smoke.imprezaapps.com", TARGET_PORT: "80", DEPLOYMENT_ID: "dpl_d0fd487b19a32ac0" }
                  created_at: "2026-05-27T22:46:13Z"
                  last_health_at: null
                  last_error: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /platform/deployments/custom/{id}:
    get:
      tags: [Platform]
      summary: Get a custom deployment
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Custom deployment row
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /platform/deployments/custom/contexts:
    post:
      tags: [Platform]
      summary: Upload a Dockerfile build context (gzip tarball)
      description: |
        Used by the Dockerfile-via-tarball deploy mode. Send the gzipped
        tar of your project as the raw request body. The response
        returns a `context_id` you reference in a follow-up
        `POST /platform/deployments/custom` body.

        Cap: 100 MB per context (operator-tunable). Context TTL: 24 h —
        after that, garbage-collected.

        The CLI (`impreza deploy`) automates this two-step flow and
        bakes in a default exclude list (`.git`, `node_modules`,
        `__pycache__`, `.venv`, `vendor`, `.impreza`, `*.pyc`,
        `.DS_Store`).
      requestBody:
        required: true
        content:
          application/gzip:
            schema:
              type: string
              format: binary
      responses:
        '201':
          description: Context staged on disk
          content:
            application/json:
              example:
                success: true
                data:
                  context_id: ctx_a1b2c3d4e5f6
                  sha256: 1f3a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a
                  size_bytes: 1048576
                  expires_at: "2026-05-28T22:46:13Z"
        '400':
          $ref: '#/components/responses/BadRequest'
        '413':
          description: Payload too large
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ═══════════════════════════════════════════════════════
  # PLATFORM — Git webhook auto-deploy
  # ═══════════════════════════════════════════════════════

  /platform/deployments/custom/{id}/git-webhook:
    get:
      tags: [Platform]
      summary: Check git auto-deploy connection status
      description: |
        Surfaces the current state of the GitHub push-webhook connection
        for a git-backed custom deployment. The UI uses this to render
        the "Connected / Not connected" badge.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Status (always 200 — even when not connected, fields just go null/empty)
          content:
            application/json:
              example:
                success: true
                data:
                  git_url: https://github.com/you/your-repo
                  branch: main
                  webhook_id: "632019164"
                  enabled: true
                  payload_url: https://api.imprezahost.com/v1/webhooks/git/dpl_d0fd487b19a32ac0
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /platform/deployments/custom/{id}/git-webhook/connect:
    post:
      tags: [Platform]
      summary: Install a GitHub push webhook for auto-deploy
      description: |
        Validates a GitHub Fine-grained Personal Access Token, mints a
        fresh per-deployment HMAC secret, installs the webhook on the
        target repository, then **discards the PAT**. From this moment
        on, every push to the watched branch triggers an automatic
        redeploy via our smart-replace path: re-clone at the new HEAD,
        rebuild, restart in place.

        The PAT needs **Repository → Webhooks: Read and write** scope
        on the target repo (and nothing else). Generate one at
        https://github.com/settings/personal-access-tokens/new.

        Returns 409 if a webhook is already connected — disconnect
        first to reconnect with a fresh secret.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [github_pat]
              properties:
                github_pat:
                  type: string
                  description: GitHub Fine-grained PAT (`github_pat_...` or `ghp_...`).
                  example: "github_pat_11AAAA..."
      responses:
        '201':
          description: Webhook installed
          content:
            application/json:
              example:
                success: true
                data:
                  webhook_id: "632019164"
                  payload_url: https://api.imprezahost.com/v1/webhooks/git/dpl_d0fd487b19a32ac0
                  branch: main
                  note: "Auto-deploy is live. Push to main to trigger a redeploy."
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Webhook already connected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /platform/deployments/custom/{id}/git-webhook/disconnect:
    post:
      tags: [Platform]
      summary: Disconnect git auto-deploy
      description: |
        Flips the local enabled flag off so further pushes 200-noop on
        the receiver.

        If a PAT is supplied, we also **DELETE the webhook from your
        repo on GitHub's side** — keeps your repo's Settings →
        Webhooks page clean. Without a PAT, only the local switch
        flips; GitHub eventually marks the hook as failing in its UI
        either way.

        Idempotent: disconnect on an already-disconnected deployment
        returns 200 noop.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                github_pat:
                  type: string
                  description: |
                    Optional. When supplied, we also DELETE the webhook
                    on GitHub. Without it, only the local enabled flag
                    flips.
                  example: "github_pat_11AAAA..."
      responses:
        '200':
          description: Disconnected (or already disconnected — idempotent)
          content:
            application/json:
              example:
                success: true
                data:
                  ok: true
                  deleted_on_github: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
