openapi: 3.1.0
info:
  title: Togle Developer API
  version: 0.6.0
  description: >-
    Implemented Stage 6 API. Developer endpoints include atomic quotas. Billing
    uses hosted checkout, signed webhooks, immutable plan versions and explicit
    state transitions. Public plan presentation and limits are administered via
    versioned plan records; Togle never accepts card data.
servers:
  - url: https://api.togle.ru
    description: Production
  - url: http://api.localhost:8080
    description: Docker development
tags:
  - { name: Diagnostics }
  - { name: Scanner }
  - { name: Usage }
  - { name: Developer account }
  - { name: Billing }
security:
  - apiKey: []
paths:
  /v1/ip/{target}:
    get:
      operationId: getIpPassport
      tags: [Diagnostics]
      parameters: [{ $ref: "#/components/parameters/Target" }]
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "401": { $ref: "#/components/responses/Problem" }
        "403": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
        "502": { $ref: "#/components/responses/Problem" }
  /v1/dns/lookup:
    post:
      operationId: lookupDns
      tags: [Diagnostics]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [name, types]
              properties:
                name: { type: string, maxLength: 253 }
                types:
                  type: array
                  minItems: 1
                  maxItems: 8
                  items: { enum: [A, AAAA, CNAME, MX, NS, TXT] }
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "400": { $ref: "#/components/responses/Problem" }
        "401": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
  /v1/rdap/lookup:
    post:
      operationId: lookupRdap
      tags: [Diagnostics]
      requestBody: { $ref: "#/components/requestBodies/TargetRequest" }
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "401": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
        "502": { $ref: "#/components/responses/Problem" }
  /v1/reverse-dns:
    post:
      operationId: reverseDns
      tags: [Diagnostics]
      requestBody: { $ref: "#/components/requestBodies/TargetRequest" }
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "401": { $ref: "#/components/responses/Problem" }
        "403": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
  /v1/subnets/calculate:
    post:
      operationId: calculateSubnet
      tags: [Diagnostics]
      requestBody: { $ref: "#/components/requestBodies/TargetRequest" }
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "401": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
  /v1/ip-utils:
    post:
      operationId: inspectIp
      tags: [Diagnostics]
      requestBody: { $ref: "#/components/requestBodies/TargetRequest" }
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "401": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
  /v1/bulk/ip:
    post:
      operationId: bulkIpLookup
      tags: [Diagnostics]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [targets]
              properties:
                targets:
                  type: array
                  minItems: 1
                  maxItems: 20
                  items: { type: string, maxLength: 253 }
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "401": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
  /v1/site-view:
    get:
      operationId: getSiteView
      tags: [Diagnostics]
      responses:
        "200": { $ref: "#/components/responses/ToolResult" }
        "401": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
  /v1/port-scans:
    post:
      operationId: createPortScan
      tags: [Scanner]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [target, mode, authorized]
              properties:
                target: { type: string, maxLength: 253 }
                mode: { enum: [quick, extended] }
                authorized: { type: boolean, const: true }
      responses:
        "202": { $ref: "#/components/responses/ScanJob" }
        "401": { $ref: "#/components/responses/Problem" }
        "403": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
  /v1/port-scans/{id}:
    parameters: [{ $ref: "#/components/parameters/JobId" }]
    get:
      operationId: getPortScan
      tags: [Scanner]
      responses:
        "200": { $ref: "#/components/responses/ScanJob" }
        "401": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
    delete:
      operationId: cancelPortScan
      tags: [Scanner]
      responses:
        "202": { description: Cancellation requested; quota is not refunded }
        "401": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "409": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
  /v1/usage:
    get:
      operationId: getUsage
      tags: [Usage]
      description: Does not consume monthly quota.
      responses:
        "200":
          description: Current calendar-month usage
          content:
            application/json: { schema: { $ref: "#/components/schemas/Usage" } }
        "401": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
  /internal/web/v1/me/api-keys:
    get:
      operationId: listApiKeys
      tags: [Developer account]
      security: [{ userSession: [] }]
      responses:
        "200":
          description: Masked keys; never returns full secrets
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  {
                    items:
                      {
                        type: array,
                        items: { $ref: "#/components/schemas/ApiKey" },
                      },
                  }
    post:
      operationId: createApiKey
      tags: [Developer account]
      security: [{ userSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [name]
              properties:
                { name: { type: string, minLength: 1, maxLength: 80 } }
      responses:
        "201":
          description: Full secret is returned exactly once
          content:
            application/json:
              { schema: { $ref: "#/components/schemas/ApiKeyCreated" } }
        "409": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
  /internal/web/v1/me/api-keys/{id}:
    delete:
      operationId: revokeApiKey
      tags: [Developer account]
      security: [{ userSession: [] }]
      parameters: [{ $ref: "#/components/parameters/JobId" }]
      responses:
        "204": { description: Key revoked }
        "404": { $ref: "#/components/responses/Problem" }
  /internal/web/v1/me/api-usage:
    get:
      operationId: getAccountApiUsage
      tags: [Developer account]
      security: [{ userSession: [] }]
      responses:
        "200":
          description: Current account API usage
          content:
            application/json: { schema: { $ref: "#/components/schemas/Usage" } }
  /internal/web/v1/me/api-logs:
    get:
      operationId: listApiLogs
      tags: [Developer account]
      security: [{ userSession: [] }]
      parameters:
        - { name: cursor, in: query, schema: { type: string, maxLength: 512 } }
        - {
            name: limit,
            in: query,
            schema: { type: integer, minimum: 1, maximum: 100, default: 30 },
          }
      responses:
        "200":
          description: Cursor-paginated request logs
          content:
            application/json:
              { schema: { $ref: "#/components/schemas/ApiLogPage" } }
        "400": { $ref: "#/components/responses/Problem" }
  /internal/web/v1/plans:
    get:
      operationId: listPlans
      tags: [Billing]
      security: []
      responses:
        "200":
          description: Active immutable plan versions
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  {
                    items:
                      {
                        type: array,
                        items: { $ref: "#/components/schemas/Plan" },
                      },
                  }
  /internal/web/v1/me/billing:
    get:
      operationId: getBillingState
      tags: [Billing]
      security: [{ userSession: [] }]
      responses:
        "200":
          description: Current entitlement, subscription and payment history
          content:
            {
              application/json:
                { schema: { $ref: "#/components/schemas/BillingState" } },
            }
        "401": { $ref: "#/components/responses/Problem" }
  /internal/web/v1/me/billing/checkout:
    post:
      operationId: createHostedCheckout
      tags: [Billing]
      security: [{ userSession: [] }]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [plan_code]
              properties:
                {
                  plan_code: { enum: [premium, premium_plus] },
                  idempotency_key: { type: string, maxLength: 128 },
                }
      responses:
        "201":
          description: Pending payment and provider-hosted checkout URL
          content:
            {
              application/json:
                { schema: { $ref: "#/components/schemas/CheckoutSession" } },
            }
        "409": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
  /internal/web/v1/me/billing/cancel:
    post:
      operationId: cancelSubscription
      tags: [Billing]
      security: [{ userSession: [] }]
      responses:
        "200":
          description: Auto-renewal disabled; entitlement remains until period_end
          content:
            {
              application/json:
                { schema: { $ref: "#/components/schemas/Subscription" } },
            }
        "404": { $ref: "#/components/responses/Problem" }
  /internal/web/v1/me/billing/resume:
    post:
      operationId: resumeSubscription
      tags: [Billing]
      security: [{ userSession: [] }]
      responses:
        "200":
          description: Auto-renewal restored before the current paid period ends
          content:
            {
              application/json:
                { schema: { $ref: "#/components/schemas/Subscription" } },
            }
        "404": { $ref: "#/components/responses/Problem" }
        "409": { $ref: "#/components/responses/Problem" }
  /internal/web/v1/me/billing/refunds:
    post:
      operationId: requestRefund
      tags: [Billing]
      security: [{ userSession: [] }]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [payment_id, reason]
              properties:
                payment_id: { type: string, format: uuid }
                reason: { type: string, minLength: 3, maxLength: 500 }
                idempotency_key: { type: string, maxLength: 128 }
      responses:
        "202":
          description: Refund requested but not sent to provider until confirmation
          content:
            {
              application/json:
                { schema: { $ref: "#/components/schemas/Refund" } },
            }
        "404": { $ref: "#/components/responses/Problem" }
  /internal/web/v1/me/billing/refunds/{id}/confirm:
    post:
      operationId: confirmRefund
      tags: [Billing]
      security: [{ userSession: [] }]
      parameters: [{ $ref: "#/components/parameters/JobId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [confirm]
              properties: { confirm: { type: boolean, const: true } }
      responses:
        "202":
          description: Confirmed refund is processing at the provider
          content:
            {
              application/json:
                { schema: { $ref: "#/components/schemas/Refund" } },
            }
        "409": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
  /internal/payments/v1/webhooks/{provider}:
    post:
      operationId: receivePaymentWebhook
      tags: [Billing]
      security: []
      parameters:
        - name: provider
          in: path
          required: true
          schema: { enum: [cloudpayments, fake] }
      description: Raw request body must carry a valid provider HMAC-SHA256 signature.
      responses:
        "200": { description: Event persisted and processed idempotently }
        "400": { $ref: "#/components/responses/Problem" }
        "401": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: tgl_live_<prefix>_<secret>
    userSession:
      type: apiKey
      in: cookie
      name: togle_user_session
  parameters:
    Target:
      {
        name: target,
        in: path,
        required: true,
        schema: { type: string, maxLength: 253 },
      }
    JobId:
      {
        name: id,
        in: path,
        required: true,
        schema: { type: string, format: uuid },
      }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, minLength: 1, maxLength: 128 }
  requestBodies:
    TargetRequest:
      required: true
      content:
        application/json:
          schema:
            type: object
            additionalProperties: false
            required: [target]
            properties: { target: { type: string, maxLength: 253 } }
  responses:
    ToolResult:
      description: Tool result
      headers:
        X-Request-ID: { schema: { type: string } }
        X-RateLimit-Limit: { schema: { type: integer } }
        X-RateLimit-Remaining: { schema: { type: integer } }
      content:
        application/json:
          { schema: { $ref: "#/components/schemas/ToolResult" } }
    ScanJob:
      description: Asynchronous scanner job
      content:
        application/json: { schema: { $ref: "#/components/schemas/ScanJob" } }
    Problem:
      description: Predictable error with a stable code and request ID
      content:
        application/problem+json:
          { schema: { $ref: "#/components/schemas/Problem" } }
  schemas:
    ToolResult:
      type: object
      required: [tool, status, result, checked_at, duration_ms]
      properties:
        run_id: { type: string, format: uuid }
        tool: { type: string }
        target: { type: string }
        status: { enum: [complete, partial, failed] }
        result: { type: object, additionalProperties: true }
        warnings: { type: array, items: { type: string } }
        checked_at: { type: string, format: date-time }
        duration_ms: { type: integer, minimum: 0 }
    Problem:
      type: object
      required: [type, title, status, code, request_id]
      properties:
        type: { type: string }
        title: { type: string }
        status: { type: integer, minimum: 400, maximum: 599 }
        code:
          enum:
            - invalid_api_key
            - monthly_quota_exhausted
            - endpoint_rate_limited
            - invalid_json
            - invalid_target
            - unsafe_target
            - provider_unavailable
            - invalid_scan
            - authorization_required
            - scan_limit_exceeded
            - scan_finished
            - not_found
            - scanner_unavailable
            - invalid_webhook_signature
            - invalid_webhook
            - plan_unavailable
            - billing_not_found
            - refund_confirmation_required
            - billing_conflict
            - billing_unavailable
        request_id: { type: string }
    Usage:
      type: object
      required: [plan, used, limit, remaining, period_start, period_end]
      properties:
        plan: { enum: [free, premium, premium_plus, bro] }
        used: { type: integer, minimum: 0 }
        limit: { type: integer, minimum: 1 }
        remaining: { type: integer, minimum: 0 }
        period_start: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
    ApiKey:
      type: object
      required: [id, name, prefix, masked, status, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        prefix: { type: string }
        masked: { type: string }
        status: { enum: [active, suspended, revoked] }
        last_used_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    ApiKeyCreated:
      allOf:
        - { $ref: "#/components/schemas/ApiKey" }
        - type: object
          required: [secret_once]
          properties: { secret_once: { type: string, writeOnly: true } }
    ApiLog:
      type: object
      required:
        [
          id,
          request_id,
          endpoint,
          method,
          path,
          status,
          duration_ms,
          created_at,
        ]
      properties:
        id: { type: string, format: uuid }
        request_id: { type: string }
        key_prefix: { type: string }
        endpoint: { type: string }
        method: { type: string }
        path: { type: string }
        status: { type: integer }
        error_code: { type: string }
        duration_ms: { type: integer }
        created_at: { type: string, format: date-time }
    ApiLogPage:
      type: object
      required: [items]
      properties:
        items: { type: array, items: { $ref: "#/components/schemas/ApiLog" } }
        next_cursor: { type: string }
    Plan:
      type: object
      required:
        [
          id,
          code,
          name,
          version,
          price_minor,
          currency,
          billing_period,
          api_monthly_limit,
        ]
      properties:
        id: { type: string, format: uuid }
        code: { enum: [free, premium, premium_plus, bro] }
        name: { type: string }
        description: { type: string, maxLength: 240 }
        cta_label: { type: string, maxLength: 80 }
        feature_items:
          type: array
          items: { type: string, maxLength: 140 }
        featured: { type: boolean }
        display_order: { type: integer, minimum: 1, maximum: 1000 }
        version: { type: integer, minimum: 1 }
        price_minor: { type: integer, minimum: 0 }
        currency: { const: RUB }
        billing_period: { enum: [none, month, year] }
        api_monthly_limit: { type: integer, minimum: 1 }
        features:
          type: object
          additionalProperties: true
    Subscription:
      type: object
      required: [id, plan_code, plan_version, status, auto_renew]
      properties:
        id: { type: string, format: uuid }
        plan_code: { enum: [premium, premium_plus] }
        plan_version: { type: integer, minimum: 1 }
        status:
          {
            enum:
              [
                inactive,
                pending,
                active,
                cancel_at_period_end,
                past_due,
                expired,
                payment_failed,
              ],
          }
        period_start: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
        auto_renew: { type: boolean }
        cancellation_requested_at: { type: string, format: date-time }
    Payment:
      type: object
      required: [id, kind, status, amount_minor, currency, provider, created_at]
      properties:
        id: { type: string, format: uuid }
        kind: { enum: [initial, recurring] }
        status:
          {
            enum:
              [
                pending,
                succeeded,
                failed,
                cancelled,
                refunded,
                partially_refunded,
              ],
          }
        amount_minor: { type: integer, minimum: 1 }
        currency: { type: string }
        provider: { type: string }
        provider_payment_id: { type: string }
        checkout_url: { type: string, format: uri }
        failure_code: { type: string }
        paid_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    Refund:
      type: object
      required:
        [id, payment_id, status, amount_minor, currency, reason, created_at]
      properties:
        id: { type: string, format: uuid }
        payment_id: { type: string, format: uuid }
        status: { enum: [requested, processing, succeeded, failed, cancelled] }
        amount_minor: { type: integer, minimum: 1 }
        currency: { type: string }
        reason: { type: string }
        provider_refund_id: { type: string }
        confirmed_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    CheckoutSession:
      type: object
      required: [payment_id, status, checkout_url]
      properties:
        payment_id: { type: string, format: uuid }
        status: { const: pending }
        checkout_url: { type: string, format: uri }
    BillingState:
      type: object
      required: [plan, entitlement, payments, refunds]
      properties:
        plan: { $ref: "#/components/schemas/Plan" }
        subscription: { $ref: "#/components/schemas/Subscription" }
        entitlement:
          type: object
          required: [source]
          properties:
            source: { enum: [default, subscription, manual, invitation] }
            valid_until: { type: string, format: date-time }
        payments:
          { type: array, items: { $ref: "#/components/schemas/Payment" } }
        refunds: { type: array, items: { $ref: "#/components/schemas/Refund" } }
    ScanJob:
      type: object
      required: [job_id, status, progress, ports]
      properties:
        job_id: { type: string, format: uuid }
        status:
          { enum: [queued, running, completed, partial, cancelled, failed] }
        progress: { type: integer, minimum: 0, maximum: 100 }
        resolved_ip: { type: string }
        ports:
          type: array
          items:
            type: object
            required: [port, status, duration_ms]
            properties:
              port: { type: integer }
              status:
                { enum: [not_checked, open, closed, no_response, unreachable] }
              duration_ms: { type: integer }
