Building a Ruby API Client: REST Requests, Signed Calls and WebSocket Streams

Written by

A Ruby script that fetches one price is easy enough to leave in a single file. Add account balances, signed requests and a feed that stays open all afternoon, and that file starts getting awkward. The HTTP code needs one sort of error handling. The streaming code needs another. Neither belongs in a Rails controller.

This walkthrough builds a small exchange client around those boundaries. Ruby handles the request parameters, signatures and JSON responses; a separate WebSocket class receives trade events. Binance Spot supplies the example endpoints, but the Ruby design is useful for other integrations too.

Start with public data. Get one request working, inspect the returned hash, then add authentication. There is no need to debug credentials and network code at the same time.

Give each Ruby class one job

Use Net::HTTP for the REST side. It keeps the first version close to Ruby’s standard library and makes the bytes being signed easy to follow. For the stream, use faye-websocket with EventMachine.

Put this in a Gemfile:

source "https://rubygems.org"

gem "faye-websocket"
gem "eventmachine"
gem "json"

Run bundle install, then create lib/exchange_client/rest_client.rb and lib/exchange_client/ws_client.rb. Keep the executable example in demo.rb at the project root.

The namespace will be ExchangeClient. Defining the module explicitly avoids the missing-constant error you can get by opening ExchangeClient::RestClient before ExchangeClient exists.

Developers coming from browser code may recognise JSON.parse. That reference describes JavaScript. Here, require "json" loads Ruby’s parser, and the resulting objects are Ruby hashes and arrays.

There is a server-side question behind this work as well: which component owns balances, order matching and outgoing events? Reading about an open source cryptocurrency exchange provides some background for that discussion. Our Ruby client has a narrower responsibility: send requests and turn responses into something the application can use.

Build the REST client before adding the stream

Save the following in lib/exchange_client/rest_client.rb:

require "net/http"
require "uri"
require "json"
require "openssl"

module ExchangeClient
  class HTTPError < StandardError
    attr_reader :status, :retry_after

    def initialize(status, retry_after)
      @status = status
      @retry_after = retry_after
      super("Exchange returned HTTP #{status}")
    end
  end

  class RestClient
    BASE_URL = "https://api.binance.com"

    def initialize(api_key: nil, api_secret: nil)
      @api_key = api_key
      @api_secret = api_secret
    end

    def ticker(symbol)
      request(Net::HTTP::Get, "/api/v3/ticker/price",
              { symbol: symbol })
    end

    def account
      request(Net::HTTP::Get, "/api/v3/account", {}, signed: true)
    end

    def limit_order(symbol:, side:, quantity:, price:,
                    client_order_id:, test: true)
      unless quantity.is_a?(String) && price.is_a?(String)
        raise ArgumentError, "Use decimal strings for quantity and price"
      end

      params = {
        symbol: symbol, side: side, type: "LIMIT",
        timeInForce: "GTC", quantity: quantity, price: price,
        newClientOrderId: client_order_id
      }
      path = test ? "/api/v3/order/test" : "/api/v3/order"
      request(Net::HTTP::Post, path, params, signed: true)
    end

    def order(symbol:, client_order_id:)
      params = { symbol: symbol, origClientOrderId: client_order_id }
      request(Net::HTTP::Get, "/api/v3/order", params, signed: true)
    end

    def cancel_order(symbol:, order_id:)
      params = { symbol: symbol, orderId: order_id }
      request(Net::HTTP::Delete, "/api/v3/order", params, signed: true)
    end

    private

    def request(request_class, path, params, signed: false)
      headers = {}

      if signed
        if @api_key.to_s.empty? || @api_secret.to_s.empty?
          raise ArgumentError, "Private requests require API credentials"
        end

        params = params.merge(
          timestamp: (Time.now.to_r * 1000).to_i,
          recvWindow: 5000
        )
        headers["X-MBX-APIKEY"] = @api_key
      end

      query = URI.encode_www_form(params)
      if signed
        signature = OpenSSL::HMAC.hexdigest("SHA256", @api_secret, query)
        query = "#{query}&signature=#{signature}"
      end

      uri = URI.join(BASE_URL, path)
      uri.query = query unless query.empty?
      http_request = request_class.new(uri.request_uri, headers)

      response = Net::HTTP.start(
        uri.host, uri.port, use_ssl: true,
        open_timeout: 5, read_timeout: 10, write_timeout: 10,
        max_retries: 0
      ) do |http|
        http.request(http_request)
      end

      unless response.is_a?(Net::HTTPSuccess)
        raise HTTPError.new(response.code.to_i, response["Retry-After"])
      end

      JSON.parse(response.body)
    end
  end
end

There are a few deliberate choices here. Public requests work without credentials. Private requests refuse to run when credentials are missing. Timeouts are set explicitly, and the underlying HTTP client’s automatic retries are disabled so the application can decide what to repeat.

The signature deserves a close look. Ruby encodes the parameters once, signs that string and sends it unchanged with the signature appended. Rebuilding the query after signing can leave the request and its signature disagreeing about the bytes.

This example supports HMAC API keys. It does not implement RSA or Ed25519 signing. The timestamp comes from the local clock, so clock synchronisation matters when private calls start failing despite apparently correct credentials.

Try a public request from a Ruby script

Create demo.rb:

require_relative "lib/exchange_client/rest_client"

client = ExchangeClient::RestClient.new
ticker = client.ticker("BTCUSDT")
puts "#{ticker.fetch('symbol')}: #{ticker.fetch('price')}"

Run it with bundle exec ruby demo.rb. Using fetch makes a missing field visible. A quiet nil is less helpful when an upstream response has changed.

Prices arrive as strings. Leave them that way while passing them through the client. If your application needs arithmetic, use BigDecimal and decide how rounding should work before converting values back to request strings. A binary floating-point approximation is an unnecessary complication here.

Add credentials only where they are needed

For account data, construct another client using environment variables:

client = ExchangeClient::RestClient.new(
  api_key: ENV.fetch("EXCHANGE_API_KEY"),
  api_secret: ENV.fetch("EXCHANGE_API_SECRET")
)

balances = client.account.fetch("balances")
puts "Received #{balances.length} balance entries"

Injecting credentials through the constructor also makes this class easier to test. A unit test can supply dummy values without changing the process environment.

Keep keys out of source files and request logs. If you use a local .env file, exclude it from version control and load it explicitly with your chosen configuration tool. A public price feed needs no key; an account reader should have only the permissions its job requires.

Validate an order without submitting it for execution

The limit_order method defaults to Binance’s test-order endpoint. It checks the request without sending an order to the matching engine. This is request validation on the configured service, not a simulated fill or a separate testnet.

require "securerandom"

client_order_id = "ruby-#{SecureRandom.hex(12)}"

result = client.limit_order(
  symbol: "BTCUSDT",
  side: "BUY",
  quantity: "0.001",
  price: "60000.00",
  client_order_id: client_order_id
)

p result

Those numbers illustrate the Ruby method call. They may fail the symbol’s current filters. Check tick size, quantity step size and notional requirements before constructing an actual order.

Passing test: false changes this method to the real order endpoint. Before doing that, persist the order intent and its client ID. If the response disappears, you need a record of what you attempted.

An order timeout leaves a question to resolve. The server may have accepted the request while the response was lost. Query the order using its client ID and reconcile its status before deciding whether to submit anything else. A client ID helps identify an order; it is not a permanent guarantee that repeated submissions cannot create another one.

Receive trade events with a separate WebSocket class

Polling works for an occasional refresh. For updates pushed over an open connection, you need a WebSocket client or another streaming mechanism supported by the service. The following example uses Binance’s public trade stream.

Save this in lib/exchange_client/ws_client.rb:

require "faye/websocket"
require "eventmachine"
require "json"

module ExchangeClient
  class WsClient
    WS_URL = "wss://stream.binance.com:9443/ws"

    def initialize(symbol, &on_trade)
      raise ArgumentError, "Provide a trade handler" unless on_trade

      @symbol = symbol.downcase
      @on_trade = on_trade
    end

    def connect
      socket = Faye::WebSocket::Client.new("#{WS_URL}/#{@symbol}@trade")

      socket.on :message do |event|
        begin
          data = JSON.parse(event.data)
        rescue JSON::ParserError
          warn "Discarded an invalid JSON message"
          next
        end

        if data.is_a?(Hash) && data["e"] == "trade"
          @on_trade.call(data)
        end
      end

      socket.on :error do |_event|
        warn "WebSocket reported a protocol error"
      end

      socket.on :close do |event|
        warn "WebSocket closed with code #{event.code}"
        EM.stop
      end

      socket
    end
  end
end

Run the stream from demo.rb after the REST example:

require_relative "lib/exchange_client/ws_client"

EM.run do
  ExchangeClient::WsClient.new("BTCUSDT") do |trade|
    puts "Trade #{trade.fetch('t')}: #{trade.fetch('p')}"
  end.connect
end

The executable owns the EventMachine loop. The client only opens a socket within it. That separation matters later when one process manages several connections.

Keep the callback short. A slow database write or synchronous HTTP request inside it can hold up other work on the reactor thread. Hand heavier processing to a worker through a bounded queue, with an explicit policy for what happens when the queue fills.

Make disconnects visible before automating recovery

This small example stops when its socket closes. A service expected to run all day needs a reconnection policy, a shutdown path and a way to show consumers that its data is stale.

Binance limits a stream connection to 24 hours, so a disconnect is not necessarily evidence of a bug. Faye’s underlying WebSocket driver replies to ping frames automatically; adding an invented socket.pong handler to this class is unnecessary.

For reconnects, replace the close handler’s EM.stop with scheduling inside the existing reactor. Keep an attempt counter across failures, cap the delay and add random variation. Reset that counter after a period of stable operation. Never call sleep in the reactor, and cancel a pending reconnect when the application is shutting down.

A trade stream reports trades. Building an order book takes additional work: a depth snapshot, buffered depth events and update-ID checks. After a sequence gap, rebuild the book. Updating one last_price variable does not reconstruct bids and asks.

Put retries and tests at the application boundary

The REST class raises an HTTPError with the status and Retry-After value. That leaves room for the caller to treat a rate limit differently from a rejected parameter. Respect the server’s delay on rate-limit responses. Repeatedly hammering the same request makes recovery harder.

A failed public read is a reasonable candidate for a bounded retry. An uncertain order submission needs reconciliation first. Avoid one broad rescue block that treats every failure as permission to send the request again.

Before using this in a Rails job or a long-running service, test the boundaries that can lose or duplicate work: the exact signed query, a timeout after submission, a malformed JSON response and a stream that closes while messages are being processed. Use recorded fixtures or test doubles for those checks.

There is plenty left to build around these classes: structured errors, connection reuse, metrics and durable order records. The useful starting point is that each concern now has somewhere to go. The REST client owns request construction. The stream client delivers events. Your Ruby application decides what those events mean and what to do when a request has no clear outcome.