Written by James Britt
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.
