openapi: 3.0.3
info:
  title: Premium Residential Proxy API
  version: "1.0.0"
  description: >
    Self-service API for BuyProxy's residential/ISP/mobile/datacenter proxy
    network. Create an account and verify it by emailed code (no browser
    needed), then create an API key from your dashboard to check your
    balance, top up by card or crypto, and generate proxy connection lines —
    all over plain JSON.


    Two kinds of API key exist. A **personal key** (`bp_key_…`, from
    Dashboard → API keys) works on the personal endpoints. A **reseller key**
    (`bp_live_…`, from Reseller → API keys, Business plan and above) works on
    the Reseller endpoints, which create isolated sub-accounts for your own
    customers and fund them from your reseller pool. Transaction lookups
    accept either.
  contact:
    name: BuyProxy Support
    url: https://www.buyproxy.org/contact
  license:
    name: Proprietary
servers:
  - url: https://www.buyproxy.org
    description: Production

tags:
  - name: Account creation
    description: No API key required — plain signup + email code verification.
  - name: Account
    description: API-key-authenticated account and billing info.
  - name: Catalog
    description: Public product catalog and live pricing — no key required.
  - name: Proxies
    description: API-key-authenticated proxy generation.
  - name: Payments
    description: Top-ups and payment status.
  - name: Reseller
    description: Reseller-key-authenticated customer management.
  - name: Reseller pool
    description: Buy bandwidth in bulk and allocate it to your customers.

paths:
  /api/auth/signup:
    post:
      tags: [Account creation]
      summary: Start signup
      description: >
        Submits an email/password and emails a 6-digit verification code.
        Nothing is persisted to the account database until that code is
        confirmed via /api/auth/verify-otp.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, minLength: 6 }
                name: { type: string, description: "Defaults to the email's local part if omitted." }
      responses:
        "200":
          description: Code sent (or a cooldown is still active from a recent request).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  cooldownSeconds: { type: integer, description: "Seconds before another code can be requested." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409":
          description: An already-verified account exists for this email.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503": { description: Signup temporarily unavailable. }

  /api/auth/verify-otp:
    post:
      tags: [Account creation]
      summary: Confirm the emailed code and create the account
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string, example: "482913" }
      responses:
        "200":
          description: Account created (or already existed).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  isNew: { type: boolean }
                  user:
                    type: object
                    properties:
                      email: { type: string }
                      name: { type: string }
        "400": { description: "Missing fields, expired, or incorrect code." }
        "410": { description: "Signup session expired — sign up again." }
        "429": { description: "Too many attempts — request a new code." }

  /api/auth/resend-otp:
    post:
      tags: [Account creation]
      summary: Reissue a verification code
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200": { description: Code reissued (subject to the same cooldown). }
        "404": { description: "No pending signup for this email — sign up first." }

  /v1/products:
    get:
      tags: [Catalog]
      summary: List products and live prices
      description: "`productKey` is what every top-up and allocation call takes."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  products:
                    type: array
                    items: { $ref: "#/components/schemas/Product" }
                  volumeBonus:
                    type: object
                    description: "Top-ups of thresholdGb or more get floor(gb × rate) extra GB free."
                    properties:
                      thresholdGb: { type: integer, example: 100 }
                      rate: { type: number, example: 0.1 }
                  plans:
                    type: array
                    items:
                      type: object
                      properties:
                        plan: { type: string, enum: [pro, business, advanced] }
                        monthlyPriceCents: { type: integer }
                        includedGb: { type: number }

  /v1/account:
    get:
      tags: [Account]
      summary: Get your account info
      security: [{ ApiKeyAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  email: { type: string }
                  name: { type: string }
                  plan: { type: string, enum: [free, pro, business, advanced, enterprise] }
                  planExpiresAt: { type: string, format: date-time, nullable: true }
                  balanceGb: { type: number, description: "Lifetime GB credited. Spendable per-product balances are on GET /v1/balances." }
                  createdAt: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/balances:
    get:
      tags: [Account]
      summary: Get your spendable bandwidth per product
      description: "`pendingGb` is paid for but still being added to a plan — it becomes `activeGb` within a few seconds."
      security: [{ ApiKeyAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  balances:
                    type: array
                    items:
                      type: object
                      properties:
                        productKey: { type: string }
                        productId: { type: string }
                        planId: { type: string, nullable: true, description: "Pass to /v1/plans/{planId}/generate." }
                        activeGb: { type: number }
                        pendingGb: { type: number }
                        totalGb: { type: number }
                        expiresAt: { type: string, format: date-time, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { description: "Balances are unavailable right now." }

  /v1/plans:
    get:
      tags: [Proxies]
      summary: List your active plans
      description: "One plan per product you hold bandwidth on. `generate` describes what that plan's generate call accepts."
      security: [{ ApiKeyAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  plans:
                    type: array
                    items: { $ref: "#/components/schemas/Plan" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { description: "Plans are unavailable right now." }

  /v1/plans/{planId}/generate:
    post:
      tags: [Proxies]
      summary: Generate proxy connection lines
      description: "Generation is free — bandwidth is only used when traffic flows. Lines are not persistent: generate fresh ones rather than storing them."
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - name: planId
          in: path
          required: true
          schema: { type: string }
          description: "A plan id from GET /v1/plans."
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GenerateRequest" }
      responses:
        "200": { $ref: "#/components/responses/GeneratedLines" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { description: "The plan has no bandwidth left." }
        "404": { description: "Plan not found." }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { description: "Could not generate a proxy right now." }

  /v1/geo/{kind}:
    get:
      tags: [Proxies]
      summary: List the locations you can target
      description: "Countries, and for Premium Residential the states and cities of a country, straight from the network's own catalog. Add format=csv for a CSV file. State and city targeting is Premium Residential only; ZIP codes are not supported. Works with a personal or a reseller API key."
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - name: kind
          in: path
          required: true
          schema: { type: string, enum: [countries, states, cities, asns] }
        - name: country
          in: query
          required: false
          schema: { type: string }
          description: "Two-letter country code, e.g. US. Required for states and cities."
        - name: state
          in: query
          required: false
          schema: { type: string }
          description: "A state value from /v1/geo/states, to list only that state's cities."
        - name: product
          in: query
          required: false
          schema: { type: string }
          description: "Product key such as premium. Defaults to Premium Residential for states and cities, and Core Residential for countries."
        - name: format
          in: query
          required: false
          schema: { type: string, enum: [json, csv] }
      responses:
        "200": { description: "A list of { name, value } items (JSON), or a CSV file." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: "That product has no such list." }

  /v1/topup/card:
    post:
      tags: [Payments]
      summary: Start a card top-up or plan subscription
      description: "Returns a Stripe Checkout link as payUrl — complete payment there, then poll GET /v1/transactions/{transactionId}. Plans are recurring subscriptions; if the account already has one, payUrl opens the subscription settings to switch plan instead and transactionId is null."
      security: [{ ApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind]
              properties:
                kind: { type: string, enum: [plan, topup] }
                plan: { type: string, enum: [pro, business, advanced], description: "Required when kind=plan." }
                interval: { type: string, enum: [month, year], default: month, description: "Billing interval for kind=plan. Yearly is ~10% cheaper." }
                productKey: { type: string, description: "Required when kind=topup — a productKey from GET /v1/products." }
                gb: { type: integer, description: "Required when kind=topup — whole GB, at least the product's minGb, at most 10000." }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  payUrl: { type: string, format: uri }
                  transactionId: { type: string, nullable: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { description: "Card payments are unavailable right now." }

  /v1/topup/crypto:
    post:
      tags: [Payments]
      summary: Start a crypto top-up
      description: "Crypto only covers pay-as-you-go top-ups — plans are card-only via /v1/topup/card."
      security: [{ ApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, productKey, gb]
              properties:
                kind: { type: string, enum: [topup] }
                productKey: { type: string, description: "A productKey from GET /v1/products." }
                gb: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri, description: "Crypto invoice URL." }
                  transactionId: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { description: "Crypto payments aren't configured." }

  /v1/transactions:
    get:
      tags: [Payments]
      summary: List your payments
      description: "Accepts a personal or a reseller key."
      security: [{ ApiKeyAuth: [] }, { ResellerApiKeyAuth: [] }]
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  transactions:
                    type: array
                    items: { $ref: "#/components/schemas/Transaction" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/transactions/{id}:
    get:
      tags: [Payments]
      summary: Get a payment's status
      description: "Poll this after a top-up. For crypto payments this also checks the payment processor live, so it settles even if a webhook was missed."
      security: [{ ApiKeyAuth: [] }, { ResellerApiKeyAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transaction" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: "No such transaction on this account." }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/me:
    get:
      tags: [Reseller]
      summary: Get your reseller account summary
      security: [{ ResellerApiKeyAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  email: { type: string }
                  customerCount: { type: integer }
                  activeCustomerCount: { type: integer }
                  pool:
                    type: array
                    items: { $ref: "#/components/schemas/PoolEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/customers:
    get:
      tags: [Reseller]
      summary: List your customers
      security: [{ ResellerApiKeyAuth: [] }]
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [active, suspended] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  customers:
                    type: array
                    items: { $ref: "#/components/schemas/Customer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Reseller]
      summary: Create a customer
      description: "Creates an isolated sub-account with no bandwidth. Fund it with POST /v1/customers/{username}/allocate."
      security: [{ ResellerApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label]
              properties:
                label: { type: string, maxLength: 100, description: "Your own name or reference for this customer." }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Customer" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/customers/{username}:
    parameters:
      - $ref: "#/components/parameters/Username"
    get:
      tags: [Reseller]
      summary: Get a customer with their plans
      security: [{ ResellerApiKeyAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Customer"
                  - type: object
                    properties:
                      plans:
                        type: array
                        items:
                          allOf:
                            - $ref: "#/components/schemas/Plan"
                            - type: object
                              properties:
                                remainingGb: { type: number, description: "Same as dataGbRemaining." }
                      pending:
                        type: array
                        items: { $ref: "#/components/schemas/PendingAllocation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: "Customer not found." }
    patch:
      tags: [Reseller]
      summary: Rename, suspend or reactivate a customer
      description: "A suspended customer can't generate new lines. Lines generated before suspension may keep working until they rotate."
      security: [{ ResellerApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, maxLength: 100 }
                status: { type: string, enum: [active, suspended] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Customer" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: "Customer not found." }

  /v1/customers/{username}/usage:
    parameters:
      - $ref: "#/components/parameters/Username"
    get:
      tags: [Reseller]
      summary: Get a customer's usage snapshot
      description: "Current remaining/used GB per plan (refreshed roughly every 30 minutes upstream) — not a time series."
      security: [{ ResellerApiKeyAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  plans:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        productId: { type: string }
                        productKey: { type: string, nullable: true }
                        remainingGb: { type: number }
                        totalGb: { type: number }
                        usedGb: { type: number }
                        expiresAt: { type: string, format: date-time, nullable: true }
                  pending:
                    type: array
                    items: { $ref: "#/components/schemas/PendingAllocation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: "Customer not found." }
        "502": { description: "Usage is unavailable right now." }

  /v1/customers/{username}/allocate:
    parameters:
      - $ref: "#/components/parameters/Username"
    post:
      tags: [Reseller pool]
      summary: Give a customer bandwidth from your pool
      description: "Moves whole GB of one product from your reseller pool onto the customer. Send an Idempotency-Key header to make retries safe."
      security: [{ ResellerApiKeyAuth: [] }]
      parameters:
        - name: Idempotency-Key
          in: header
          schema: { type: string, maxLength: 200 }
          description: "A repeated key returns alreadyApplied: true and moves nothing."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [productKey, gb]
              properties:
                productKey: { type: string }
                gb: { type: integer, description: "Whole GB, at least the product's minGb." }
      responses:
        "201":
          description: Allocated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Allocation" }
        "200":
          description: Idempotency-Key already used — nothing new moved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Allocation" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { description: "Not enough of that product in your pool." }
        "404": { description: "Customer not found." }

  /v1/customers/{username}/generate:
    parameters:
      - $ref: "#/components/parameters/Username"
    post:
      tags: [Reseller]
      summary: Generate proxy lines for a customer
      security: [{ ResellerApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [planId]
                  properties:
                    planId: { type: string, description: "A plan id from GET /v1/customers/{username}." }
                - $ref: "#/components/schemas/GenerateRequest"
      responses:
        "200": { $ref: "#/components/responses/GeneratedLines" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { description: "The plan has no bandwidth left." }
        "403": { description: "The customer is suspended." }
        "404": { description: "Customer or plan not found." }
        "502": { description: "Could not generate a proxy right now." }

  /v1/pool:
    get:
      tags: [Reseller pool]
      summary: Get your unallocated reseller bandwidth
      security: [{ ResellerApiKeyAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  pool:
                    type: array
                    items: { $ref: "#/components/schemas/PoolEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/pool/topup/card:
    post:
      tags: [Reseller pool]
      summary: Buy bandwidth into your pool by card
      description: "Priced like a normal top-up (including the volume bonus). Poll GET /v1/transactions/{transactionId}; the GB lands in your pool when it settles."
      security: [{ ResellerApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PoolTopupRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  payUrl: { type: string, format: uri }
                  transactionId: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { description: "Card payments are unavailable right now." }

  /v1/pool/topup/crypto:
    post:
      tags: [Reseller pool]
      summary: Buy bandwidth into your pool with crypto
      security: [{ ResellerApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PoolTopupRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
                  transactionId: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { description: "Crypto payments aren't configured." }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: "Personal key (bp_key_…) — create one from https://www.buyproxy.org/dashboard/api-keys after verifying your account."
    ResellerApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: "Reseller key (bp_live_…) — create one from https://www.buyproxy.org/dashboard/reseller/api-keys. Requires a Business plan or higher."
  parameters:
    Username:
      name: username
      in: path
      required: true
      schema: { type: string }
      description: "The customer's username from POST /v1/customers."
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
    Product:
      type: object
      properties:
        productKey: { type: string, enum: [residential, premium, isp, mobile, ipv4, ipv6] }
        productId: { type: string }
        name: { type: string }
        description: { type: string }
        minGb: { type: integer }
        pricePerGbCents: { type: integer, nullable: true, description: "Null if pricing is momentarily unavailable." }
        currency: { type: string, example: usd }
    Plan:
      type: object
      properties:
        id: { type: string }
        productId: { type: string }
        productKey: { type: string, nullable: true }
        name: { type: string }
        label: { type: string, nullable: true }
        dataGbRemaining: { type: number }
        dataGbTotal: { type: number }
        expiresAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        generate:
          type: object
          nullable: true
          description: "What this plan's generate call accepts."
          properties:
            maxCount: { type: integer }
            maxStickyMinutes: { type: integer, description: "0 means no sticky sessions on this product." }
            protocols:
              type: array
              items: { type: string, enum: [http, socks5] }
            targeting:
              type: object
              properties:
                country: { type: boolean }
                state: { type: boolean }
                city: { type: boolean }
                isp: { type: boolean }
                asn: { type: boolean }
                os: { type: boolean }
                strict: { type: boolean }
                excludeCountries: { type: boolean }
                excludeStates: { type: boolean }
                excludeCities: { type: boolean }
                excludeAsn: { type: boolean }
            minStickyMinutes: { type: integer, nullable: true }
            stickyTakesDuration: { type: boolean, nullable: true, description: "false: sticky has no chosen length (Core Residential)." }
            udp: { type: boolean }
    GenerateRequest:
      type: object
      properties:
        protocol: { type: string, enum: [http, socks5], default: http }
        count: { type: integer, minimum: 1, maximum: 1000, default: 1 }
        country: { type: string, description: "ISO 3166-1 alpha-2 country code, e.g. US." }
        state: { type: string, description: "A state from GET /v1/geo/states. Premium Residential only; not together with city." }
        city: { type: string, description: "A city from GET /v1/geo/cities, accents allowed (São Paulo). Premium Residential only; not together with state." }
        asn: { type: integer, description: "Target one network by AS number, e.g. 7922. Premium Residential only; needs a country." }
        os: { type: string, enum: [windows, android, ios, mac], description: "Premium Residential only." }
        strict: { type: boolean, default: true, description: "false: when no IP is free at the target, use another IP in the same country. Premium Residential only." }
        excludeCountries: { type: array, items: { type: string }, maxItems: 20, description: "Premium Residential only. One exclusion kind per call; not with country." }
        excludeStates: { type: array, items: { type: string }, maxItems: 20, description: "Premium Residential only; needs a country." }
        excludeCities: { type: array, items: { type: string }, maxItems: 20, description: "Premium Residential only; needs a country." }
        excludeAsn: { type: array, items: { type: integer }, maxItems: 20, description: "Premium Residential only." }
        udp: { type: boolean, description: "Let a SOCKS5 line open UDP relays. Premium Residential only." }
        stickyMinutes:
          type: integer
          minimum: 0
          description: "Omit for a fresh IP per request (rotating). Set for a sticky session, up to the plan's maxStickyMinutes."
    Transaction:
      type: object
      properties:
        id: { type: string }
        kind: { type: string, enum: [plan, topup] }
        status: { type: string, enum: [pending, succeeded, failed] }
        paymentMethod: { type: string, enum: [card, crypto] }
        amountCents: { type: integer }
        currency: { type: string }
        gb: { type: number, nullable: true, description: "GB credited, including any volume bonus." }
        productKey: { type: string, nullable: true }
        plan: { type: string, nullable: true }
        interval: { type: string, enum: [month, year], nullable: true, description: "Billing interval, for plan payments." }
        resellerPool: { type: boolean, description: "True for a reseller pool purchase." }
        createdAt: { type: string, format: date-time }
    Customer:
      type: object
      properties:
        username: { type: string }
        label: { type: string }
        status: { type: string, enum: [active, suspended] }
        createdAt: { type: string, format: date-time }
    PendingAllocation:
      type: object
      description: "GB allocated but still being added to a plan."
      properties:
        productId: { type: string }
        productKey: { type: string, nullable: true }
        gb: { type: number }
    PoolEntry:
      type: object
      properties:
        productKey: { type: string }
        productId: { type: string }
        label: { type: string }
        gb: { type: number }
    PoolTopupRequest:
      type: object
      required: [productKey, gb]
      properties:
        productKey: { type: string }
        gb: { type: integer, description: "Whole GB, at least the product's minGb, at most 10000." }
    Allocation:
      type: object
      properties:
        productKey: { type: string }
        productId: { type: string }
        gb: { type: integer }
        alreadyApplied: { type: boolean }
        poolRemainingGb: { type: number }
  responses:
    BadRequest:
      description: Invalid request body.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid X-API-Key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: "The account is suspended, or (reseller endpoints) is no longer on a Business plan or higher."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: More than 60 requests/minute on this key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    GeneratedLines:
      description: OK
      content:
        application/json:
          schema:
            type: object
            properties:
              lines:
                type: array
                description: "Each line is username:password@host:port."
                items: { type: string }
