Building a Crypto Exchange API Client in Ruby (REST + WebSocket)

Sooner or later, every Ruby developer involved in fintech or crypto faces the same challenge: tweaking an exchange’s API. Sometimes it’s a trading bot, sometimes an internal dashboard displaying balances and open orders, sometimes a back-office script reconciling trades. In all these cases, the logic is the same: some data is retrieved via REST, some via WebSocket, and both parts need to reside in a single client, without becoming a hodgepodge of disparate scripts.

In this article, we’ll build just such a client in Ruby 3.x: with public and private REST requests and a real-time stream via WebSocket. The difference between these two approaches is simple. REST is a classic request-response model: you query your balance or submit an order, and the exchange responds once. WebSocket is a persistent connection through which the server automatically sends order book, ticker, and trade updates as they occur. Now, let’s get hands-on.

Selecting tools (Ruby setup)

For REST requests, Faraday is sufficient —it’s more convenient than bare net/http due to its middleware and readable syntax. For WebSockets, we’ll use faye-websocket over EventMachine —a proven and well-documented combination. Request signing will be done using the standard openssl, and JSON parsing will be done using the built-in json.

Binance’s public docs are a good starting point, but they only show you the client side of the equation. If you want to see the same REST/WebSocket layer from the server’s perspective — how order matching, balances, and market data feeds are actually implemented — it’s worth studying the codebase of an open source cryptocurrency exchange; it makes the architecture we’re about to build in Ruby much easier to reason about.

ruby

# Gemfile

source "https://rubygems.org"

gem "faraday"

gem "faye-websocket"

gem "eventmachine"

gem "json"

gem "dotenv"

We keep the file structure simple: one class for REST, one for WebSocket, and a common config:

lib/

exchange_client/

config.rb

rest_client.rb

ws_client.rb

exchange_client.rb

This separation allows you to test the REST and WS parts independently and not mix synchronous and asynchronous code in the same class.

Connecting to the REST API

Public endpoints

The simplest case is data that doesn’t require authorization: the current price, order book, or a list of trading pairs. This is a standard GET request.

ruby

require Faraday

require "json"

class ExchangeClient :: RestClient

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

  def initialize

    @conn = Faraday. new ( url : BASE_URL ) do |f|

f. request :url_encoded

f. adapterFaraday. default_adapter

    end

  end

  def ticker ( symbol )

response = @conn. get ( "/api/v3/ticker/price", symbol : symbol )

    JSON. parse ( response.body )​​

  end

end

The method sends a GET to a public endpoint, passes the pair symbol as a parameter, and parses the exchange’s response into a Ruby hash. No keys are required here—this is open market data.

Authenticated requests

For private endpoints—to retrieve account data (including balances), Binance requires HMAC-SHA256 of the parameter string plus the header with the public key.

ruby

require "openssl"

class ExchangeClient :: RestClient

  def initialize ( api_key : ENV. fetch ( "EXCHANGE_API_KEY" ),

                  api_secret : ENV. fetch ( "EXCHANGE_API_SECRET" ))

    @api_key = api_key

    @api_secret = api_secret

    @conn = Faraday. new ( url : BASE_URL )

  end

  def sign ( params )

query = URI. encode_www_form ( params )

signature = OpenSSL :: HMAC. hexdigest ( "SHA256", @api_secret, query )

query + "&signature= #{ signature } "

  end

  def balances

params = { timestamp : ( Time. now. to_f * 1000 ). to_i }

signed_query = sign ( params )

response = @conn. get ( "/api/v3/account? #{ signed_query } " ) do |req|

req. headers [ "X-MBX-APIKEY" ] = @api_key

    end

    JSON. parse ( response.body )​​

  end

end

sign method assembles a string of parameters, adds a timestamp (the exchange rejects requests with an old timestamp), and signs the result with a secret key using HMAC-SHA256. The signature is added to the query string, and the public key is transmitted as a separate header.

An important security point: keys are always read from environment variables via ENV.fetch, not hardcoded. Use dotenv locally and environment secrets in production—hardcoded keys in the repository will leak sooner or later.

Placing and canceling an order

With a signature in hand, adding and canceling an order is a matter of a couple of methods.

ruby

def place_order ( symbol :, side :, quantity :, price : )

params = {

    symbol : symbol, side : side, type : "LIMIT",

    timeInForce : "GTC", quantity : quantity, price : price,

    timestamp : ( Time. now. to_f * 1000 ). to_i

  }

signed_query = sign ( params )

response = @conn. post ( "/api/v3/order? #{ signed_query } " ) do |req|

req. headers [ "X-MBX-APIKEY" ] = @api_key

  end

  JSON. parse ( response.body )​​

end

def cancel_order ( symbol :, order_id : )

params = { symbol : symbol, orderId : order_id,

             timestamp : ( Time. now. to_f * 1000 ). to_i }

signed_query = sign ( params )

response = @conn. delete ( "/api/v3/order? #{ signed_query } " ) do |req|

req. headers [ "X-MBX-APIKEY" ] = @api_key

  end

  JSON. parse ( response.body )​​

end

Both methods reuse the familiar sign method ; only the set of parameters and the HTTP verb differ. The exchange’s response should always be analyzed for the error field—more on that below.

Streaming data via WebSocket

REST is good for one-time requests, but for a real-time order book or feed, you need a WebSocket—a persistent connection to which the exchange automatically pushes updates.

ruby

require "faye/websocket"

require "eventmachine"

require "json"

class ExchangeClient :: WsClient

  WS_URL = "wss://stream.binance.com:9443/ws"

  def subscribe ( symbol )

@symbol = symbol

    EM. run do

ws = Faye :: WebSocket :: Client. new ( " #{ WS_URL } / #{ symbol. downcase } @trade" )

ws. on :open do

puts "WS connected to #{ symbol } "

      end

ws. on :message do |event|

data = JSON. parse ( event.data )​​

handle_trade ( data )

      end

ws. on :close do |event|

puts "WS closed: #{ event. code } #{ event. reason } "

      end

    end

  end

  def handle_trade ( data )

    # updating the local state of the order book/price

    @last_price = data [ "p" ]

  end

end

Here, we connect to the trade stream for a specific pair, parse the JSON for each incoming message, and update the local state—in this case, the latest trade price. The logic is similar for the order book, except the message contains bid/ask levels that need to be merged into the local snapshot.

Maintaining the connection

A long-lived connection must survive network interruptions and periods of inactivity. Binance itself sends ping frames, which must be responded to with pong, otherwise the server will close the socket.

ruby

ws. on :ping do |event|

ws. pong ( event. data )

end

ws. on :close do |event|

puts "Disconnected, reconnecting..."

reconnect_with_backoff

end

def reconnect_with_backoff ( attempt = 1 )

  delay = [ 2** attempt, 30 ].min

  EM. add_timer ( delay ) { subscribe ( @symbol ) }

end

reconnect_with_backoff increases the pause between attempts exponentially, but no more than 30 seconds. This protects both your network and the exchange server from unnecessary load during mass disconnections.

Error handling, request limits, and resilience

Exchanges return standard HTTP codes: 429 indicates a request limit exceeded, 5xx indicates a problem on the exchange’s end, and 4xx (other than 429) usually indicates an error in the request itself. A reasonable strategy is to retry temporary errors with exponential backoff and not retry those caused by invalid parameters.

ruby

def with_retries ( max_attempts : 5 )

attempt = 0

  begin

attempt += 1

    yield

  rescue Faraday::TimeoutError => e

    if attempt < max_attempts

sleep ( 2 **attempt )

      retry

    else

      raise e

    end

  end

end

Order idempotency is a separate issue. If an order request has been sent but the response hasn’t arrived (timed out), resubmitting it can create a duplicate. Most exchanges use a clientOrderId for this purpose — a unique identifier that you generate yourself and pass in the request; the exchange will reject a duplicate with the same ID. Create such an identifier for each order and log it along with the exchange’s response—this greatly simplifies incident investigation and reconciliation of trades retroactively.

Timeouts on the HTTP client should be explicitly set, rather than relying on library defaults—a frozen connection without a timeout can hold your bot’s thread for hours.

Putting it all together

The final example shows how both clients work side by side: REST for a one-time balance request and order placement, and WebSocket for a constant price feed.

ruby

require_relative "lib/exchange_client"

rest = ExchangeClient :: RestClient. new

puts rest. balances

order = rest. place_order (

  symbol : "BTCUSDT", side : "BUY", quantity : 0.001, price : 60,000

)

puts order

ws = ExchangeClient :: WsClient. new

ws. subscribe ( "BTCUSDT" )

Conclusion

This client is already a working framework, not a toy: signed requests, secure key storage, WebSocket reconnection, and basic resilience to network failures. The next logical steps are to cover the code with tests (Faraday and WS mocks are perfect for this), wrap everything in a full-fledged gem with a clear public API, and think about production details: centralized logging, latency and reconnection metrics, and separate configurations for the test and production networks. With this foundation, you can further expand the logic – from a simple dashboard to a full-fledged trading bot.

Leave a Reply

Your email address will not be published. Required fields are marked *