openapi: 3.1.0
info:
  title: NonVoIP User API
  version: '1.0.0'
  description: |
    Public User API for NonVoIP 2.0 (`/api/v1/*`).

    Authenticate with a Bearer API key from Dashboard → API. Money-moving POST
    requests require an `Idempotency-Key` header. This specification contains only
    user-facing endpoints — no admin or provider surfaces.
  contact:
    name: NonVoIP Support
servers:
  - url: https://nonvoip.com/api/v1
    description: Production
security:
  - UserBearer: []
tags:
  - name: Account
  - name: Auth
  - name: Catalog
  - name: Orders
paths:
  /auth/refresh:
    post:
      tags: [Auth]
      operationId: refreshSession
      summary: Rotate refresh cookie and issue a new access JWT
      description: |
        Browser session endpoint. Reads HttpOnly refresh cookie (`nv_user_rt`),
        rotates it (365-day rolling expiry), and returns a new 30-minute access token.
        Reuse of a rotated refresh token revokes all web sessions for that user.
      security: []
      responses:
        '200':
          description: New access token
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
        '401':
          description: Missing/expired/reused refresh session
  /auth/logout:
    post:
      tags: [Auth]
      operationId: logout
      summary: Revoke current refresh session and clear cookie
      security: []
      responses:
        '200':
          description: Logged out
  /auth/forgot-password:
    post:
      tags: [Auth]
      operationId: forgotPassword
      summary: Request a password reset email
      description: |
        Public browser auth endpoint. Always returns `{ sent: true }` (enumeration-safe).
        Requires Cloudflare Turnstile. Reset links expire in 30 minutes; only the most
        recent link is valid. Rate limits apply per email (HMAC) and IP.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, turnstile_token]
              properties:
                email:
                  type: string
                  format: email
                turnstile_token:
                  type: string
      responses:
        '200':
          description: Generic acceptance (does not reveal whether the account exists)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [sent]
                        properties:
                          sent:
                            type: boolean
                            const: true
        '400':
          description: CAPTCHA_FAILED or validation error
  /auth/reset-password:
    post:
      tags: [Auth]
      operationId: resetPassword
      summary: Reset password with emailed token
      description: |
        Public browser auth endpoint. Consumes a single-use reset token (TTL 30 minutes),
        updates the password hash, bumps `token_version` (revokes outstanding user JWTs),
        and invalidates other unused reset tokens. Does not return a session — caller must
        sign in. API keys are not revoked.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token:
                  type: string
                  description: Opaque token from the reset email (never log or display)
                password:
                  type: string
                  minLength: 8
      responses:
        '200':
          description: Password updated
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [reset]
                        properties:
                          reset:
                            type: boolean
                            const: true
        '401':
          description: PASSWORD_RESET_INVALID — link invalid or expired
  /account:
    get:
      tags: [Account]
      operationId: getAccount
      summary: Get account and wallet balance
      description: |
        Canonical public Account endpoint. Returns account information and
        current wallet balance (USD). Manage API keys, IP whitelist, and SMS
        webhook URL in Dashboard → API. Add funds via Dashboard → Deposit.
      responses:
        '200':
          description: Account and balance
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/AccountData'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/IpNotWhitelisted'
  /products:
    get:
      tags: [Catalog]
      operationId: listProducts
      summary: List products
      parameters:
        - name: type
          in: query
          required: true
          schema:
            type: string
            enum: [short-term, long-term, dedicated]
        - name: country
          in: query
          description: Dedicated country (US, GB, or CA). UK normalizes to GB. Carrier is assigned by the platform — do not send carrier.
          schema:
            type: string
            enum: [US, GB, CA]
      responses:
        '200':
          description: Catalog
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
  /products/{service_id}:
    get:
      tags: [Catalog]
      operationId: getProduct
      summary: Product detail
      parameters:
        - name: service_id
          in: path
          required: true
          schema:
            type: string
            example: svc:dedicated-number:dedicated_lt
        - name: type
          in: query
          required: true
          schema:
            type: string
            enum: [short-term, long-term, dedicated]
        - name: country
          in: query
          description: Dedicated country filter (US, GB, or CA). UK normalizes to GB. Carrier is assigned by the platform — do not send carrier.
          schema:
            type: string
            enum: [US, GB, CA]
      responses:
        '200':
          description: Product
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
  /orders:
    get:
      tags: [Orders]
      operationId: listOrders
      summary: List orders
      parameters:
        - name: type
          in: query
          required: true
          schema:
            type: string
            enum: [short-term, long-term, dedicated]
        - name: scope
          in: query
          schema:
            type: string
            enum: [active, history, complete, all]
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 15
      responses:
        '200':
          description: Order list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
    post:
      tags: [Orders]
      operationId: createOrder
      summary: Create order
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderRequest'
      responses:
        '201':
          description: Order created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
        '409':
          description: PRICE_CHANGED (catalog price increased above quoted_price)
  /orders/{id}:
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Get order
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: Order
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
  /orders/{id}/messages:
    get:
      tags: [Orders]
      operationId: getOrderMessages
      summary: Get order messages
      parameters:
        - $ref: '#/components/parameters/ResourceId'
        - name: sync
          in: query
          schema:
            type: string
            enum: ['0', '1']
            default: '0'
      responses:
        '200':
          description: Message array in data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
  /orders/messages:
    get:
      tags: [Orders]
      operationId: getBatchOrderMessages
      summary: Batch messages
      parameters:
        - name: order_ids
          in: query
          required: true
          schema:
            type: string
        - name: sync
          in: query
          schema:
            type: string
            enum: ['0', '1']
      responses:
        '200':
          description: Map of order_id to message arrays
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
  /orders/{id}/refund-request:
    post:
      tags: [Orders]
      operationId: refundRequest
      summary: Request refund
      description: >
        After MDN assignment, short-term and long-term orders may call refund-request
        immediately (no min-wait). Dedicated orders require at least 5 minutes since
        allocated_at (fallback created_at); early dedicated attempts return HTTP 422 with
        ORDER_CANCEL_NOT_AVAILABLE. Also requires no deliverable SMS and (for user cancel)
        within the 60-minute refund window.
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '202':
          description: >
            Refund accepted for processing. If a refund is already in progress, returns 202 again
            with status processing (idempotent success, not an error).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
        '422':
          description: >
            Not eligible — for example ORDER_HAS_SMS, REFUND_WINDOW_EXPIRED, or
            ORDER_CANCEL_NOT_AVAILABLE (dedicated before 5 minutes).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /orders/{id}/cancel:
    post:
      tags: [Orders]
      operationId: cancelOrder
      summary: Cancel pending order (pre-allocation only)
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: Cancelled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
  /orders/{id}/renew:
    post:
      tags: [Orders]
      operationId: renewOrder
      summary: Renew long-term or dedicated order
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: Renewed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
  /orders/{id}/extend-session:
    post:
      tags: [Orders]
      operationId: extendSession
      summary: Extend session (compatibility)
      description: >
        Compatibility alias. Prefer POST /orders/{id}/reuse for short-term Renew /
        Reuse session when can_reuse_session or can_extend_session is true.
        Same billing as /reuse: first renew 50% of original price; already renewed full price.
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: New child order created (see data.order_id)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
        '422':
          description: EXTEND_NOT_AVAILABLE or INSUFFICIENT_BALANCE
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /orders/{id}/reuse:
    post:
      tags: [Orders]
      operationId: reuseSession
      summary: Reuse session (STR renew)
      description: >
        Short-term Renew / Reuse session — same as the dashboard Renew button.
        Call when can_reuse_session or can_extend_session is true. Creates a new
        ~10-minute short-term order and returns data.order_id for the child order.
        Billing: first renew charges 50% of the original customer price; already-renewed
        orders charge the full original price. Wallet debit only after renew succeeds.
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: New child order created (see data.order_id)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
        '422':
          description: REUSE_NOT_AVAILABLE or INSUFFICIENT_BALANCE
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /orders/{id}/go-online:
    post:
      tags: [Orders]
      operationId: goOnline
      summary: Go online (LTR)
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: Online window opened
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
  /orders/{id}/auto-renew:
    post:
      tags: [Orders]
      operationId: setAutoRenew
      summary: Toggle auto-renew
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled:
                  type: boolean
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
components:
  securitySchemes:
    UserBearer:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: API key from Dashboard → API (prefix nv_live_)
  parameters:
    ResourceId:
      name: id
      in: path
      required: true
      schema:
        type: string
    IdempotencyKeyRequired:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
  responses:
    Unauthorized:
      description: AUTH_REQUIRED, INVALID_API_KEY, or API_KEY_EXPIRED
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    IpNotWhitelisted:
      description: IP_NOT_WHITELISTED — request IP is not allowed by the account API whitelist
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
  schemas:
    AccountData:
      type: object
      required: [name, email, balance]
      properties:
        name:
          type: string
          example: John Doe
        email:
          type: string
          example: john@example.com
        balance:
          type: string
          description: Wallet balance in USD
          example: '120.50'
    SuccessEnvelope:
      type: object
      required: [success, data, error, meta]
      properties:
        success:
          type: boolean
          const: true
        data: {}
        error:
          type: 'null'
        meta:
          type: object
          properties:
            request_id:
              type: string
            timestamp:
              type: string
              format: date-time
    ErrorEnvelope:
      type: object
      required: [success, data, error, meta]
      properties:
        success:
          type: boolean
          const: false
        data:
          type: 'null'
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            details: {}
        meta:
          type: object
          properties:
            request_id:
              type: string
            timestamp:
              type: string
              format: date-time
    CreateOrderRequest:
      type: object
      required: [type, service_id, offer_id, quoted_price]
      properties:
        type:
          type: string
          enum: [short-term, long-term, dedicated]
        service_id:
          type: string
          description: Catalog service_id. Dedicated uses svc:dedicated-number:dedicated_lt.
          example: svc:dedicated-number:dedicated_lt
        offer_id:
          type: string
          description: Required. For dedicated, send the catalog selected_offer_id (numeric string). For short-term and long-term, send the chosen offers[].id.
          example: '42'
        quoted_price:
          type: string
          description: Latest catalog customer_price you are confirming. If the latest price is higher than quoted_price, the API returns PRICE_CHANGED.
          example: '14.50'
        country:
          type: string
          enum: [US, GB, CA]
          description: Required for dedicated. UK normalizes to GB. Carrier is assigned by the platform — do not send carrier.
          example: US
        service:
          type: string
          description: Legacy slug alternative to service_id
