openapi: 3.1.0
info:
  title: Hypercall API
  version: "1.0"
  description: |
    Residential proxy control API.

    Authenticate with your account API key (Bearer `uk_…`) on `/api/*`.
    `/stats/{token}` and `/locations/*` need no authentication.

    Byte convention: 1 GB = 1e9 bytes. Every account obeys
    `remaining = max_bytes - allocated_bytes - bytes_used`.
servers:
  - url: https://api.hypercall.dev

tags:
  - name: Account
    description: Your own account.
  - name: Sub-users
    description: Accounts you created and fund.
  - name: Public
    description: No authentication required.

security:
  - userKey: []

paths:
  /api/me:
    get:
      tags: [Account]
      summary: Your account
      responses:
        "200":
          description: Account summary
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/me/proxies:
    get:
      tags: [Account]
      summary: Generate proxy strings
      parameters:
        - { name: country, in: query, schema: { type: string }, description: "ISO-2 code, e.g. us" }
        - { name: state, in: query, schema: { type: string } }
        - { name: city, in: query, schema: { type: string } }
        - { name: asn, in: query, schema: { type: string } }
        - { name: zip, in: query, schema: { type: string } }
        - { name: continent, in: query, schema: { type: string } }
        - { name: sid, in: query, schema: { type: string, maxLength: 60 }, description: "Sticky session id, up to 60 chars. Not combinable with count > 1" }
        - { name: ttl, in: query, schema: { type: integer }, description: "Minutes, 10-1440" }
        - { name: count, in: query, schema: { type: integer, default: 1, minimum: 1, maximum: 100 }, description: "1-100. Each line gets its own random session id. Not combinable with sid" }
        - { name: strict, in: query, schema: { type: string }, description: "false/0/off/no to relax" }
        - name: format
          in: query
          schema: { type: string, enum: [json, txt] }
          description: "txt returns host:port:user:pass lines"
      responses:
        "200":
          description: Proxy strings
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ProxyString" }
            text/plain:
              schema: { type: string }

  /api/me/rotate-password:
    post:
      tags: [Account]
      summary: Rotate your proxy password
      description: Returns a freshly generated password **once**. Takes effect network-wide within seconds.
      responses:
        "200":
          description: New password
          content:
            application/json:
              schema:
                type: object
                properties: { proxy_password: { type: string } }

  /api/me/rotate-key:
    post:
      tags: [Account]
      summary: Rotate your API key
      description: |
        Mints a new key and **revokes every other key on the account, including
        the one used for this request**. Switch to the returned key immediately.
      responses:
        "200":
          description: New API key
          content:
            application/json:
              schema:
                type: object
                properties: { api_key: { type: string } }

  /api/nodes:
    get:
      tags: [Account]
      summary: Usable edge nodes
      responses:
        "200":
          description: Active nodes with a public host
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Node" } }

  /api/users:
    get:
      tags: [Sub-users]
      summary: List your sub-users
      responses:
        "200":
          description: Sub-users
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/User" } }
    post:
      tags: [Sub-users]
      summary: Create a sub-user
      description: |
        Moves `gb` out of your budget into theirs, atomically. Rejected with a
        billing `422` if you lack the balance. Requires `can_resell`.
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewUser" }
      responses:
        "200":
          description: Created. The API key is shown once
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
                  api_key: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/BillingError" }

  /api/users/{id}:
    parameters: [{ $ref: "#/components/parameters/UserId" }]
    get:
      tags: [Sub-users]
      summary: Get a sub-user
      responses:
        "200":
          description: Sub-user
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Sub-users]
      summary: Update a sub-user
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                status: { type: string, enum: [active, suspended] }
                can_resell: { type: boolean }
                thread_cap: { type: integer, nullable: true }
                speed_limit_mbps: { type: number, nullable: true }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
    delete:
      tags: [Sub-users]
      summary: Delete a sub-user
      description: Their **unused** balance returns to you. Spent bytes are not reclaimed.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties: { deleted: { type: boolean } }

  /api/users/{id}/allocate:
    parameters: [{ $ref: "#/components/parameters/UserId" }]
    post:
      tags: [Sub-users]
      summary: Move GB into a sub-user
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [gb]
              properties: { gb: { type: number, example: 50 } }
      responses:
        "200":
          description: Updated sub-user
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "422": { $ref: "#/components/responses/BillingError" }

  /api/users/{id}/refund:
    parameters: [{ $ref: "#/components/parameters/UserId" }]
    post:
      tags: [Sub-users]
      summary: Reclaim unused GB from a sub-user
      description: Capped at what they haven't spent. Omit `gb` to reclaim everything unused.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties: { gb: { type: number, nullable: true } }
      responses:
        "200":
          description: Updated sub-user
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }

  /api/users/{id}/rotate-password:
    parameters: [{ $ref: "#/components/parameters/UserId" }]
    post:
      tags: [Sub-users]
      summary: Rotate a sub-user's proxy password
      responses:
        "200":
          description: New password, shown once
          content:
            application/json:
              schema:
                type: object
                properties: { proxy_password: { type: string } }

  /api/users/{id}/allowed-ips:
    parameters: [{ $ref: "#/components/parameters/UserId" }]
    put:
      tags: [Sub-users]
      summary: Restrict where credentials may be used
      description: Replaces the list. Empty = unrestricted. Checked against the real peer.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                cidrs: { type: array, items: { type: string, example: "203.0.113.0/24" } }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  allowed_ips: { type: array, items: { type: string } }

  /api/users/{id}/ip-auths:
    parameters: [{ $ref: "#/components/parameters/UserId" }]
    put:
      tags: [Sub-users]
      summary: Set IP-based authentication
      description: Client IPs that authenticate as this account with no password.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                ips: { type: array, items: { type: string, example: "203.0.113.7" } }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ip_auths: { type: array, items: { type: string } }

  /api/users/{id}/proxies:
    parameters: [{ $ref: "#/components/parameters/UserId" }]
    get:
      tags: [Sub-users]
      summary: Generate proxy strings for a sub-user
      description: Same parameters as `/api/me/proxies`.
      responses:
        "200":
          description: Proxy strings
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/ProxyString" } }

  /stats/{token}:
    parameters:
      - name: token
        in: path
        required: true
        schema: { type: string }
        description: The account's opaque `stats_token`
    get:
      tags: [Public]
      security: []
      summary: Usage by stats token
      parameters:
        - { name: format, in: query, schema: { type: string, enum: [json, txt] } }
      responses:
        "200":
          description: Usage totals
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stats" }
            text/plain:
              schema: { type: string, example: "used=2885007 total=500000000000 remaining=499997114993", description: "total and remaining read `unlimited` on unlimited accounts" }

  /locations/countries:
    get:
      tags: [Public]
      security: []
      summary: Countries with available exits
      responses:
        "200":
          description: ISO-2 codes
          content:
            application/json:
              schema: { type: array, items: { type: string } }

  /locations/regions:
    get:
      tags: [Public]
      security: []
      summary: Regions with available exits
      parameters: [{ name: country, in: query, schema: { type: string } }]
      responses:
        "200":
          description: Region names
          content:
            application/json:
              schema: { type: array, items: { type: string } }

  /locations/cities:
    get:
      tags: [Public]
      security: []
      summary: Cities with available exits
      parameters:
        - { name: country, in: query, schema: { type: string } }
        - { name: region, in: query, schema: { type: string } }
      responses:
        "200":
          description: City names
          content:
            application/json:
              schema: { type: array, items: { type: string } }

  /locations/asns:
    get:
      tags: [Public]
      security: []
      summary: ASNs with available exits
      parameters: [{ name: country, in: query, schema: { type: string } }]
      responses:
        "200":
          description: ASN numbers
          content:
            application/json:
              schema: { type: array, items: { type: string } }

components:
  securitySchemes:
    userKey:
      type: http
      scheme: bearer
      description: "Account API key (`uk_…`)"

  parameters:
    UserId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string }
      description: Retrying with the same key replays the original response.

  responses:
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema:
            type: object
            properties: { message: { type: string, example: "Invalid API key." } }
    Forbidden:
      description: Account inactive, or lacks the capability
      content:
        application/json:
          schema:
            type: object
            properties: { message: { type: string } }
    NotFound:
      description: Not found, or not yours (never disclosed which)
      content:
        application/json:
          schema:
            type: object
            properties: { message: { type: string, example: "Not found." } }
    BillingError:
      description: Billing failure, e.g. insufficient balance
      content:
        application/json:
          schema:
            type: object
            properties: { error: { type: string, example: "Insufficient GB balance." } }

  schemas:
    User:
      type: object
      properties:
        id: { type: string, format: uuid }
        parent_id: { type: string, format: uuid, nullable: true, description: "null = top-level account" }
        can_resell: { type: boolean, description: "May create sub-users" }
        name: { type: string }
        status: { type: string, enum: [active, suspended, depleted, expired] }
        proxy_username: { type: string }
        proxy_password: { type: string }
        stats_token: { type: string }
        max_bytes: { type: integer, nullable: true, description: "null = unlimited" }
        allocated_bytes: { type: integer, description: "Handed down to sub-users" }
        bytes_used: { type: integer, description: "Own proxy traffic only" }
        remaining_bytes: { type: integer, nullable: true }
        thread_cap: { type: integer, nullable: true }
        speed_limit_mbps: { type: number, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        ip_auths: { type: array, items: { type: string } }
        ip_restrictions: { type: array, items: { type: string } }
        created_at: { type: string, format: date-time }

    NewUser:
      type: object
      required: [name]
      properties:
        name: { type: string }
        gb: { type: number, nullable: true, description: "null = unlimited (only from an unlimited parent)" }
        can_resell: { type: boolean, default: false }
        proxy_username: { type: string, description: "Generated when omitted" }
        proxy_password: { type: string, description: "Generated when omitted" }
        thread_cap: { type: integer, nullable: true }
        speed_limit_mbps: { type: number, nullable: true }
        expires_days: { type: integer, nullable: true }

    ProxyString:
      type: object
      properties:
        server: { type: string, description: "Friendly name of the entry node" }
        host: { type: string }
        http_port: { type: integer, example: 4444 }
        socks5_port: { type: integer, example: 1080 }
        username: { type: string }
        password: { type: string }
        http_url: { type: string }
        socks5_url: { type: string }

    Node:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        host: { type: string }
        region: { type: string }
        http_port: { type: integer }
        socks5_port: { type: integer }

    Stats:
      type: object
      properties:
        name: { type: string }
        status: { type: string }
        max_bytes: { type: integer, nullable: true }
        allocated_bytes: { type: integer }
        bytes_used: { type: integer }
        remaining_bytes: { type: integer, nullable: true }
        analytics_total_bytes: { type: integer, nullable: true, description: "Omitted when analytics are unavailable" }
