openapi: 3.0.3
info:
  title: Exchange Public API v1
  version: 1.0.0
  description: |
    The exchange's public trading API: market data without a key, private
    endpoints with an `api_key`/`api_secret` pair and an HMAC request signature.

    Rules that hold across the whole surface:

    - **Time** is always epoch milliseconds, as a number.
    - **Money and quantities** are strings (a JSON number loses precision at 8
      decimals), and they must be sent as strings too.
    - **Errors** share one envelope, `{"code","message"}`; a 422 adds `errors` in
      Laravel's format. `code` is machine-readable and stable, `message` is for
      humans.
    - **Pagination** is the same everywhere: the cursor `from_id` (strictly
      greater) plus `limit` (1..1000, 100 by default). There is no offset. The
      last page is the one shorter than the requested `limit`.
    - **Nothing is clamped silently:** a `limit` outside the bounds is a 422, not
      a quiet 1000.
    - **CORS is disabled deliberately.** This surface is server-to-server; a
      secret that reaches a browser leaks with the page.

    Signing, examples in bash/Python/Node, the table of error codes and the sharp
    edges (idempotency, replay protection, withdrawal conditions) are in the
    [integrator guide](/api-docs).
  contact:
    name: Exchange API support
    email: support@amlkyc.tech

servers:
  - url: https://{host}/api/v1
    description: Production (the domain of any brand)
    variables:
      host:
        default: wallex.zone
        description: Exchange domain
  - url: http://localhost/api/v1
    description: Local development

tags:
  - name: System
    description: Service endpoints
  - name: Market Data
    description: Public market data, no key required
  - name: Account
    description: Reading your own account, `read` scope
  - name: Trading
    description: Placing and cancelling orders, `trade` scope
  - name: Withdrawals
    description: Withdrawing funds, `withdraw` scope

# By default an endpoint requires a signed request; public ones declare `security: []` explicitly.
security:
  - ApiKeyAuth: []
    ApiTimestamp: []
    ApiSignature: []

paths:
  /time:
    get:
      summary: Server time
      description: |
        Needed before the first signature: a timestamp outside the `recv_window`
        (5000 ms by default) is rejected as `stale_timestamp`.
      operationId: getServerTime
      tags: [Market Data]
      security: []
      responses:
        '200':
          description: Server time
          content:
            application/json:
              schema:
                type: object
                properties:
                  server_time:
                    type: integer
                    format: int64
                    description: Epoch milliseconds
                    example: 1757500000123
        '429':
          $ref: '#/components/responses/RateLimited'

  /symbols:
    get:
      summary: Trading pair specifications
      description: |
        Markets with the `delisted` status are not shown and return no data at all
        (404 `unknown_symbol` on every endpoint). A `paused` market is visible with
        its status: a market that simply vanished from the list reads to a client
        as an integration bug.
      operationId: getSymbols
      tags: [Market Data]
      security: []
      responses:
        '200':
          description: List of markets
          content:
            application/json:
              schema:
                type: object
                properties:
                  symbols:
                    type: array
                    items:
                      $ref: '#/components/schemas/Symbol'
        '429':
          $ref: '#/components/responses/RateLimited'

  /ticker:
    get:
      summary: 24-hour ticker
      description: |
        The response is always a list, even for a single requested symbol: a shape
        that depends on the presence of a parameter breaks typed clients. Without
        `symbol` every market is returned.
      operationId: getTicker
      tags: [Market Data]
      security: []
      parameters:
        - $ref: '#/components/parameters/OptionalSymbol'
      responses:
        '200':
          description: Tickers
          content:
            application/json:
              schema:
                type: object
                properties:
                  tickers:
                    type: array
                    items:
                      $ref: '#/components/schemas/Ticker'
        '404':
          $ref: '#/components/responses/UnknownSymbol'
        '429':
          $ref: '#/components/responses/RateLimited'

  /depth:
    get:
      summary: Order book
      operationId: getDepth
      tags: [Market Data]
      security: []
      parameters:
        - $ref: '#/components/parameters/RequiredSymbol'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Order book snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Depth'
        '404':
          $ref: '#/components/responses/UnknownSymbol'
        '422':
          $ref: '#/components/responses/InvalidParameter'
        '429':
          $ref: '#/components/responses/RateLimited'

  /trades:
    get:
      summary: Market trade feed
      description: Newest first. These are the market's trades, not yours — yours live in `/my-trades`.
      operationId: getMarketTrades
      tags: [Market Data]
      security: []
      parameters:
        - $ref: '#/components/parameters/RequiredSymbol'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Recent trades
          content:
            application/json:
              schema:
                type: object
                properties:
                  symbol:
                    type: string
                    example: BTC_USDT
                  trades:
                    type: array
                    items:
                      $ref: '#/components/schemas/MarketTrade'
        '404':
          $ref: '#/components/responses/UnknownSymbol'
        '422':
          $ref: '#/components/responses/InvalidParameter'
        '429':
          $ref: '#/components/responses/RateLimited'

  /klines:
    get:
      summary: Candles
      description: Oldest to newest — the order every charting library expects.
      operationId: getKlines
      tags: [Market Data]
      security: []
      parameters:
        - $ref: '#/components/parameters/RequiredSymbol'
        - name: interval
          in: query
          required: false
          description: Candle size. An unknown value is a 422 `invalid_parameter`.
          schema:
            type: string
            enum: ['1m', '5m', '1h', '1d']
            default: '1m'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Candles
          content:
            application/json:
              schema:
                type: object
                properties:
                  symbol:
                    type: string
                    example: BTC_USDT
                  interval:
                    type: string
                    example: 1m
                  klines:
                    type: array
                    items:
                      $ref: '#/components/schemas/Kline'
        '404':
          $ref: '#/components/responses/UnknownSymbol'
        '422':
          $ref: '#/components/responses/InvalidParameter'
        '429':
          $ref: '#/components/responses/RateLimited'

  /funding:
    get:
      summary: Funding rate (perpetuals only)
      description: |
        For a spot market this is a 422 `invalid_parameter`: a zero rate would mean
        funding exists here and happens to be zero.
      operationId: getFunding
      tags: [Market Data]
      security: []
      parameters:
        - $ref: '#/components/parameters/RequiredSymbol'
      responses:
        '200':
          description: Current rate
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Funding'
        '404':
          $ref: '#/components/responses/UnknownSymbol'
        '422':
          $ref: '#/components/responses/InvalidParameter'
        '429':
          $ref: '#/components/responses/RateLimited'

  /ping:
    parameters:
      - $ref: '#/components/parameters/RecvWindow'
    get:
      summary: Check the key and the signature
      description: |
        Answers only a correctly signed request — a convenient place to debug your
        signing implementation without risking an order. It requires no scope, but
        shares the rate limit bucket with reads (`api-v1-read`).
      operationId: ping
      tags: [System]
      responses:
        '200':
          description: The key is alive and the signature is valid
          content:
            application/json:
              schema:
                type: object
                properties:
                  user_id:
                    type: integer
                    example: 42
                  key:
                    type: string
                    example: ek_9f1c0f5e0a4b4f2f9d3a5c7e1b2d4f60
                  scopes:
                    type: array
                    items:
                      type: string
                      enum: [read, trade, withdraw]
                  server_time:
                    type: integer
                    format: int64
                    example: 1757500000123
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /account:
    parameters:
      - $ref: '#/components/parameters/RecvWindow'
    get:
      summary: Profile of the key and its owner
      description: |
        `can_trade`/`can_withdraw` are strictly about the KEY (its scopes), while
        the account gates come back as separate fields next to them. Otherwise
        `can_withdraw: false` would be ambiguous: "the key was not granted the
        right" or "the account is not ready".
      operationId: getAccount
      tags: [Account]
      responses:
        '200':
          description: Profile
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /account/balances:
    parameters:
      - $ref: '#/components/parameters/RecvWindow'
    get:
      summary: Balances per asset and sub-account
      description: |
        Demo and real funds never mix: a demo account only sees `scope: demo`, a
        regular one only the real sub-accounts. The account mode is in
        `GET /account` (`is_demo`).
      operationId: getBalances
      tags: [Account]
      responses:
        '200':
          description: Balances
          content:
            application/json:
              schema:
                type: object
                properties:
                  balances:
                    type: array
                    items:
                      $ref: '#/components/schemas/Balance'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /account/positions:
    parameters:
      - $ref: '#/components/parameters/RecvWindow'
    get:
      summary: Open perpetual positions
      description: Zero-size positions are not returned — that is the trace of a close, not a position.
      operationId: getPositions
      tags: [Account]
      responses:
        '200':
          description: Positions
          content:
            application/json:
              schema:
                type: object
                properties:
                  positions:
                    type: array
                    items:
                      $ref: '#/components/schemas/Position'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /orders:
    parameters:
      - $ref: '#/components/parameters/RecvWindow'
    get:
      summary: Order history
      description: |
        An order on a delisted pair stays in the history and is returned when no
        filter is set, but you cannot filter by that symbol: the `symbol` of a
        delisted market is a 404 `unknown_symbol`.
      operationId: getOrders
      tags: [Account]
      parameters:
        - $ref: '#/components/parameters/OptionalSymbol'
        - name: status
          in: query
          required: false
          description: |
            Filter by status. Case is forgiven on input, an unknown value is a 422
            `invalid_parameter` listing the allowed ones.
          schema:
            $ref: '#/components/schemas/OrderStatus'
        - $ref: '#/components/parameters/FromId'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of history
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/UnknownSymbol'
        '422':
          $ref: '#/components/responses/InvalidParameter'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      summary: Place an order
      description: |
        **Idempotency.** A repeat with an already used `client_order_id` returns
        the same order and **200** instead of **201** — the only way to tell
        "created" from "already existed" without adding a field the other
        endpoints do not have. The `client_order_id` space is yours: someone
        else's identifier will not return your order.

        **`post_only` is not resolved synchronously.** An order that would cross
        the book goes to `pending` and becomes `rejected` later, inside the
        engine: the `POST` response does not show this, the status has to be
        polled.

        A domain rejection arrives as a 422 with a machine-readable `code`
        (`insufficient_funds`, `tick_lot_mismatch`, `notional_too_small`, …);
        a trading pause is a 503 `trading_paused`, the only rejection that clears
        on its own.
      operationId: placeOrder
      tags: [Trading]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlaceOrderRequest'
      responses:
        '201':
          description: Order created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderEnvelope'
        '200':
          description: A repeat by `client_order_id` — the previously created order came back
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/UnknownSymbol'
        '422':
          description: Request body error or a trading domain rejection
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
              examples:
                insufficientFunds:
                  value:
                    code: insufficient_funds
                    message: Insufficient balance to place this order.
                    errors:
                      quantity: [Insufficient balance to place this order.]
                badBody:
                  value:
                    code: invalid_parameter
                    message: The request body did not pass validation.
                    errors:
                      quantity: [The quantity must be sent as a JSON string (e.g. "0.015") to avoid float rounding.]
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Trading is paused — retry later
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
              example:
                code: trading_paused
                message: Trading is paused for maintenance.
                errors:
                  symbol: [Trading is paused for maintenance.]
    delete:
      summary: Cancel every active order on a symbol
      description: |
        `symbol` is required. A missing parameter is a 422, not "cancel
        everything": reading a typo as a command is far too expensive. The
        response carries `canceled` and `failed` lists rather than a counter — the
        client needs to know exactly what is still resting.
      operationId: cancelOrders
      tags: [Trading]
      parameters:
        - $ref: '#/components/parameters/RequiredSymbol'
      responses:
        '200':
          description: Result of the bulk cancellation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelAllResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/UnknownSymbol'
        '422':
          $ref: '#/components/responses/InvalidParameter'
        '429':
          $ref: '#/components/responses/RateLimited'

  /orders/open:
    parameters:
      - $ref: '#/components/parameters/RecvWindow'
    get:
      summary: Active orders
      description: |
        `pending`, `open` and `partially_filled`. Pagination is the same as for the
        history: a market maker with a thousand orders would otherwise get a
        silently truncated list.
      operationId: getOpenOrders
      tags: [Account]
      parameters:
        - $ref: '#/components/parameters/OptionalSymbol'
        - $ref: '#/components/parameters/FromId'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Active orders
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/UnknownSymbol'
        '422':
          $ref: '#/components/responses/InvalidParameter'
        '429':
          $ref: '#/components/responses/RateLimited'

  /orders/{id}:
    parameters:
      - $ref: '#/components/parameters/OrderId'
      - $ref: '#/components/parameters/RecvWindow'
    get:
      summary: A single order
      description: |
        Someone else's `id`, a non-existent one and a non-numeric one all answer
        the same — 404 `order_not_found`: a different answer would confirm that an
        order exists and hand out a map of other people's identifiers by
        enumeration.
      operationId: getOrder
      tags: [Account]
      responses:
        '200':
          description: Order
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/OrderNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      summary: Cancel an order
      description: |
        Idempotent: an already cancelled or filled order is a 200 with the current
        state, not a 404 and not a 409. "Nothing left to do" is the `status` field
        in the response. Cancelling an order that has reached the engine is
        asynchronous: the response may still carry the previous status.
      operationId: cancelOrder
      tags: [Trading]
      responses:
        '200':
          description: Current state of the order
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/OrderNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /my-trades:
    parameters:
      - $ref: '#/components/parameters/RecvWindow'
    get:
      summary: Your own fills
      description: |
        **Order fills only.** Liquidations and funding payments have no order
        behind them and never appear here — perpetual PnL computed from trades
        alone will diverge from the balance. Their movement is visible in the
        ledger and in the position's `realized_pnl`.
      operationId: getMyTrades
      tags: [Account]
      parameters:
        - $ref: '#/components/parameters/OptionalSymbol'
        - $ref: '#/components/parameters/FromId'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of fills
          content:
            application/json:
              schema:
                type: object
                properties:
                  trades:
                    type: array
                    items:
                      $ref: '#/components/schemas/Fill'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/UnknownSymbol'
        '422':
          $ref: '#/components/responses/InvalidParameter'
        '429':
          $ref: '#/components/responses/RateLimited'

  /deposits:
    parameters:
      - $ref: '#/components/parameters/RecvWindow'
    get:
      summary: Deposit history
      operationId: getDeposits
      tags: [Account]
      parameters:
        - $ref: '#/components/parameters/FromId'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of deposits
          content:
            application/json:
              schema:
                type: object
                properties:
                  deposits:
                    type: array
                    items:
                      $ref: '#/components/schemas/Deposit'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/InvalidParameter'
        '429':
          $ref: '#/components/responses/RateLimited'

  /withdrawals:
    parameters:
      - $ref: '#/components/parameters/RecvWindow'
    get:
      summary: Withdrawal history
      description: The `read` scope (not `withdraw`). Bank details are never exposed.
      operationId: getWithdrawals
      tags: [Account]
      parameters:
        - $ref: '#/components/parameters/FromId'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of withdrawals
          content:
            application/json:
              schema:
                type: object
                properties:
                  withdrawals:
                    type: array
                    items:
                      $ref: '#/components/schemas/Withdrawal'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/InvalidParameter'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      summary: Withdraw funds to a blockchain network
      description: |
        The `withdraw` scope. A bot cannot type a TOTP code, so the second factor
        is REPLACED by four conditions — all checked before any money moves:

        1. the key has a non-empty `ip_allowlist` (otherwise 403 `ip_allowlist_required`);
        2. the account has 2FA enabled (otherwise 403 `two_factor_required`);
        3. the address is in the account's address book for this asset+network pair
           (otherwise 403 `address_not_whitelisted`);
        4. the address has survived a 24-hour cooldown since it was added
           (otherwise 403 `address_in_cooldown`).

        Plus the general gates: passed KYC (403 `kyc_required`), an unblocked
        account (403 `account_blocked`) and the demo ban (422
        `demo_withdraw_forbidden`). There is no bank withdrawal here: bank details
        have no address, so there is nothing to replace the second factor with.

        The request stores the address spelling from the address book, not from
        the request.
      operationId: createWithdrawal
      tags: [Withdrawals]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWithdrawalRequest'
      responses:
        '201':
          description: Withdrawal created
          content:
            application/json:
              schema:
                type: object
                properties:
                  withdrawal:
                    $ref: '#/components/schemas/Withdrawal'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: One of the withdrawal conditions is not met
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                ipAllowlistRequired:
                  value:
                    code: ip_allowlist_required
                    message: Withdrawals require an API key restricted to an IP allowlist. Add one in your account settings.
                addressNotWhitelisted:
                  value:
                    code: address_not_whitelisted
                    message: This address is not in your withdrawal address book for this asset and network.
                addressInCooldown:
                  value:
                    code: address_in_cooldown
                    message: This address is still in its cooldown period and becomes usable at 2026-09-12T10:00:00+00:00.
                kycRequired:
                  value:
                    code: kyc_required
                    message: Withdrawals require identity verification.
        '404':
          description: No asset with this symbol exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                code: unknown_asset
                message: Unknown asset `XYZ`.
        '422':
          description: Body error, insufficient funds, demo account or a non-withdrawable asset
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
              examples:
                insufficientFunds:
                  value:
                    code: insufficient_funds
                    message: Not enough available balance for this withdrawal.
                assetNotWithdrawable:
                  value:
                    code: asset_not_withdrawable
                    message: Asset `BYN` cannot be withdrawn to a blockchain network.
                demoForbidden:
                  value:
                    code: demo_withdraw_forbidden
                    message: Demo accounts cannot withdraw.
        '429':
          $ref: '#/components/responses/RateLimited'

components:

  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-KEY
      description: The key's public identifier (`ek_…`), issued in your account.
    ApiTimestamp:
      type: apiKey
      in: header
      name: X-API-TIMESTAMP
      description: |
        Epoch milliseconds. The window is two-sided: a timestamp from the future
        is rejected just like one from the past. The timestamp must be strictly
        monotonic — two identical requests with one timestamp produce one
        signature, and the second comes back as `replayed_request`.
    ApiSignature:
      type: apiKey
      in: header
      name: X-API-SIGNATURE
      description: |
        `lowercase(hex(hmac_sha256(api_secret, canonical)))`, where
        `canonical = TIMESTAMP + "\n" + METHOD + "\n" + PATH + "\n" + QUERY + "\n" + BODY`.
        `PATH` carries a leading slash and no host (`/api/v1/orders`), `QUERY` is
        the raw string without `?` and WITHOUT reordering, `BODY` is the raw body
        as sent. Details and examples are in the [integrator guide](/api-docs).

  parameters:
    RecvWindow:
      name: X-API-RECV-WINDOW
      in: header
      required: false
      description: |
        Signature accept window in milliseconds. 5000 by default, 60000 maximum.
        Zero, a non-number or a value above the maximum is a 401 `bad_recv_window`.
      schema:
        type: integer
        minimum: 1
        maximum: 60000
        default: 5000
    RequiredSymbol:
      name: symbol
      in: query
      required: true
      description: Market symbol. An unknown one (including delisted) is a 404 `unknown_symbol`.
      schema:
        type: string
        example: BTC_USDT
    OptionalSymbol:
      name: symbol
      in: query
      required: false
      description: |
        Market filter. An empty value equals no parameter; an unknown symbol is a
        404 `unknown_symbol` (not an empty list, which would hide the typo).
      schema:
        type: string
        example: BTC_USDT
    Limit:
      name: limit
      in: query
      required: false
      description: Page size. Outside the bounds it is a 422 `invalid_parameter`; nothing is clamped silently.
      schema:
        type: integer
        minimum: 1
        maximum: 1000
        default: 100
    FromId:
      name: from_id
      in: query
      required: false
      description: |
        Cursor: records with a STRICTLY greater `id` are returned, in ascending
        order. Usually this is the `id` of the last record you already have.
      schema:
        type: integer
        minimum: 0
        example: 0
    OrderId:
      name: id
      in: path
      required: true
      description: The exchange order identifier (not `client_order_id`).
      schema:
        type: integer
        example: 1024

  responses:
    Unauthorized:
      description: |
        The key or the signature was not accepted. `code`: `missing_credentials`,
        `invalid_key`, `key_revoked`, `key_expired`, `invalid_signature`,
        `stale_timestamp`, `replayed_request`, `bad_recv_window`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: invalid_signature
            message: Request signature does not match.
    Forbidden:
      description: |
        The key is valid but the action is forbidden. `code`:
        `insufficient_scope`, `ip_not_allowed`, `account_blocked`, `kyc_required`
        and the withdrawal condition codes.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: insufficient_scope
            message: 'This API key is missing a required scope: trade.'
    UnknownSymbol:
      description: The market is unknown to this surface (no such symbol, or it is delisted)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: unknown_symbol
            message: Unknown symbol `BTC_USD`. See GET /api/v1/symbols for the list.
    OrderNotFound:
      description: No such order on this account (someone else's, non-existent or non-numeric id)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: order_not_found
            message: No such order for this account.
    InvalidParameter:
      description: A request parameter was not accepted
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: invalid_parameter
            message: Query parameter `limit` must be between 1 and 1000.
    RateLimited:
      description: |
        Rate limit exceeded. A separate bucket counts FAILED authentications per
        IP — it answers with `rate_limit_exceeded` as well.
      headers:
        Retry-After:
          description: How many seconds to wait before retrying
          schema:
            type: integer
        X-RateLimit-Limit:
          description: Bucket size
          schema:
            type: integer
        X-RateLimit-Remaining:
          description: What is left in the bucket
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: rate_limit_exceeded
            message: Too many requests. Retry after the number of seconds in the Retry-After header.

  schemas:

    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: Machine-readable error code, the stable part of the contract
          example: unknown_symbol
        message:
          type: string
          description: Human-readable text; never branch on it
          example: Unknown symbol `BTC_USD`. See GET /api/v1/symbols for the list.

    ValidationError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            errors:
              type: object
              description: Field → list of messages (Laravel format)
              additionalProperties:
                type: array
                items:
                  type: string

    OrderStatus:
      type: string
      enum: [pending, open, partially_filled, filled, canceled, rejected]
      description: |
        `pending` — accepted and sent to the engine; `open` — resting in the book;
        `rejected` — the engine refused it (a `post_only` order that crossed the
        book, for example).

    Symbol:
      type: object
      properties:
        symbol:
          type: string
          example: BTC_USDT
        base_asset:
          type: string
          example: BTC
        quote_asset:
          type: string
          example: USDT
        base_scale:
          type: integer
          description: Number of decimals of the base asset
          example: 8
        quote_scale:
          type: integer
          example: 2
        market_type:
          type: string
          enum: [spot, perp]
        status:
          type: string
          enum: [active, paused]
          description: Delisted markets are not shown at all
        tick_size:
          type: string
          example: '0.01'
        lot_size:
          type: string
          example: '0.00001'
        min_notional:
          type: string
          example: '10'
        max_notional:
          type: string
          nullable: true
          example: '100000'
        maker_fee:
          type: string
          description: A fraction, not a percentage
          example: '0.001'
        taker_fee:
          type: string
          example: '0.002'
        max_leverage:
          type: integer
          nullable: true
          example: 20

    Ticker:
      type: object
      properties:
        symbol:
          type: string
          example: BTC_USDT
        last:
          type: string
          nullable: true
          example: '64150.10'
        open:
          type: string
          nullable: true
        high:
          type: string
          nullable: true
        low:
          type: string
          nullable: true
        change:
          type: string
          nullable: true
          description: 24-hour price change in the quote asset
        volume:
          type: string
          description: 24-hour volume in the base asset
          example: '12.34567890'
        quote_volume:
          type: string
          example: '791234.56'
        trade_count:
          type: integer
          example: 3120
        time:
          type: integer
          format: int64
          example: 1757500000123

    Depth:
      type: object
      properties:
        symbol:
          type: string
          example: BTC_USDT
        sequence:
          type: integer
          format: int64
          description: Book snapshot number; 0 when there is no snapshot yet
          example: 918273
        time:
          type: integer
          format: int64
        bids:
          type: array
          description: Bid levels, positional ["price","quantity"] pairs
          items:
            $ref: '#/components/schemas/DepthLevel'
        asks:
          type: array
          items:
            $ref: '#/components/schemas/DepthLevel'

    DepthLevel:
      type: array
      minItems: 2
      maxItems: 2
      items:
        type: string
      example: ['64150.10', '0.51000000']

    MarketTrade:
      type: object
      properties:
        id:
          type: integer
          format: int64
        price:
          type: string
          example: '64150.10'
        quantity:
          type: string
          example: '0.01000000'
        side:
          type: string
          enum: [buy, sell]
          description: The taker's side
        sequence:
          type: integer
          format: int64
        time:
          type: integer
          format: int64
        is_demo:
          type: boolean
          description: A demo trade; counted in the ticker volume

    Kline:
      type: object
      properties:
        open_time:
          type: integer
          format: int64
          description: Start of the interval
        open:
          type: string
        high:
          type: string
        low:
          type: string
        close:
          type: string
        volume:
          type: string
          description: Volume in the base asset
        quote_volume:
          type: string
        trades:
          type: integer

    Funding:
      type: object
      properties:
        symbol:
          type: string
          example: BTC_USDT_PERP
        funding_rate:
          type: string
          nullable: true
          example: '0.0001'
        next_funding_time:
          type: integer
          format: int64
          nullable: true
          description: null when no next funding is scheduled
        interval_seconds:
          type: integer
          nullable: true
          example: 28800

    Account:
      type: object
      properties:
        user_id:
          type: integer
        is_demo:
          type: boolean
          description: Account mode; demo and real data never mix anywhere on this surface
        kyc_status:
          type: string
          enum: [none, new, process, moderate, fail, success, cancelled]
        kyc_verified:
          type: boolean
        two_factor_enabled:
          type: boolean
          description: Mandatory for withdrawals through the API
        api_key:
          type: string
          description: public_id of the key that made the request
          example: ek_9f1c0f5e0a4b4f2f9d3a5c7e1b2d4f60
        scopes:
          type: array
          items:
            type: string
            enum: [read, trade, withdraw]
        can_trade:
          type: boolean
          description: Whether the KEY has the trade scope (account gates are separate fields)
        can_withdraw:
          type: boolean
        server_time:
          type: integer
          format: int64

    Balance:
      type: object
      properties:
        asset:
          type: string
          example: USDT
        scope:
          type: string
          enum: [wallet, spot, futures, demo]
          description: Ledger sub-account. `spot` is a legacy account, zeroed by a migration.
        available:
          type: string
          example: '1500.00'
        locked:
          type: string
          description: Reserved for active orders
          example: '250.00'

    Position:
      type: object
      properties:
        symbol:
          type: string
        side:
          type: string
          enum: [long, short]
        size:
          type: string
          description: Absolute size; the direction is in the side field
        entry_price:
          type: string
        mark_price:
          type: string
          nullable: true
        margin:
          type: string
        leverage:
          type: integer
        unrealized_pnl:
          type: string
          nullable: true
          description: null when there is no mark price
        realized_pnl:
          type: string
          description: Includes funding and liquidation results, which are absent from /my-trades
        liquidation_price:
          type: string
          nullable: true

    Order:
      type: object
      properties:
        id:
          type: integer
          format: int64
        client_order_id:
          type: string
          nullable: true
          description: Your identifier; unique within the account
        symbol:
          type: string
        side:
          type: string
          enum: [buy, sell]
        type:
          type: string
          enum: [limit, market]
        tif:
          type: string
          enum: [gtc, ioc, fok]
        status:
          $ref: '#/components/schemas/OrderStatus'
        price:
          type: string
          nullable: true
          description: null for a market order
        quantity:
          type: string
        remaining:
          type: string
        filled:
          type: string
          description: quantity - remaining, computed on the server
        post_only:
          type: boolean
        reduce_only:
          type: boolean
        leverage:
          type: integer
          nullable: true
        fee:
          type: string
          description: A zero fee is "0", not null
        fee_asset:
          type: string
          nullable: true
        is_demo:
          type: boolean
        created_at:
          type: integer
          format: int64
        updated_at:
          type: integer
          format: int64

    OrderEnvelope:
      type: object
      properties:
        order:
          $ref: '#/components/schemas/Order'

    OrderList:
      type: object
      properties:
        orders:
          type: array
          description: An empty array means no more data; a length below limit means this is the last page
          items:
            $ref: '#/components/schemas/Order'

    Fill:
      type: object
      properties:
        id:
          type: integer
          format: int64
          description: The cursor for from_id
        order_id:
          type: integer
          format: int64
        symbol:
          type: string
        side:
          type: string
          enum: [buy, sell]
        is_maker:
          type: boolean
        price:
          type: string
        quantity:
          type: string
        quote_quantity:
          type: string
        fee:
          type: string
        fee_asset:
          type: string
          nullable: true
        is_demo:
          type: boolean
        time:
          type: integer
          format: int64

    Deposit:
      type: object
      properties:
        id:
          type: integer
          format: int64
        asset:
          type: string
          nullable: true
          description: null for an unknown token that arrived at your address
        network:
          type: string
          example: trx
        address:
          type: string
        tx_id:
          type: string
          nullable: true
        amount:
          type: string
        confirmations:
          type: integer
        required_confirmations:
          type: integer
        status:
          type: string
          enum: [pending, confirmed, credited, ignored]
        created_at:
          type: integer
          format: int64
        credited_at:
          type: integer
          format: int64
          nullable: true

    Withdrawal:
      type: object
      properties:
        id:
          type: integer
          format: int64
        asset:
          type: string
        type:
          type: string
          enum: [crypto, bank]
          description: Only `crypto` is created through the API; `bank` may appear in the history from the account UI
        amount:
          type: string
        fee:
          type: string
        address:
          type: string
          nullable: true
          description: The spelling comes from the address book, not from the request
        network:
          type: string
          nullable: true
        status:
          type: string
          enum: [pending, processing, completed, rejected]
        created_at:
          type: integer
          format: int64
        updated_at:
          type: integer
          format: int64

    CancelAllResult:
      type: object
      properties:
        symbol:
          type: string
        canceled:
          type: array
          items:
            type: integer
            format: int64
        failed:
          type: array
          description: Orders that could not be cancelled — re-read their state
          items:
            type: integer
            format: int64

    PlaceOrderRequest:
      type: object
      required: [symbol, side, type, quantity]
      properties:
        symbol:
          type: string
          maxLength: 64
          example: BTC_USDT
        side:
          type: string
          enum: [buy, sell]
        type:
          type: string
          enum: [limit, market]
        quantity:
          type: string
          description: STRING ONLY — a JSON number means the amount already went through a float
          example: '0.015'
        price:
          type: string
          nullable: true
          description: 'Required for `type: limit`, string only'
          example: '64150.10'
        tif:
          type: string
          enum: [gtc, ioc, fok]
          nullable: true
        post_only:
          type: boolean
          description: |
            Not allowed on a market order. A post-only order that crosses the book
            is rejected by the engine asynchronously — the status has to be polled.
        reduce_only:
          type: boolean
          description: Perpetuals only
        leverage:
          type: integer
          minimum: 1
          nullable: true
          description: Required for perpetuals except reduce-only orders (there it comes from the position)
        client_order_id:
          type: string
          maxLength: 128
          nullable: true
          description: Idempotency key within your account
          example: mm-btc-1757500000123

    CreateWithdrawalRequest:
      type: object
      required: [asset, network, address, amount]
      properties:
        asset:
          type: string
          maxLength: 32
          example: USDT
        network:
          type: string
          maxLength: 32
          description: Normalized before the check ('TRC-20' → 'trx')
          example: trx
        address:
          type: string
          maxLength: 255
          description: Must be in the address book for this asset+network pair
        amount:
          type: string
          description: String only; the minimum comes from the asset settings
          example: '100.5'
