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.
