{"id":1103,"date":"2026-07-02T09:15:30","date_gmt":"2026-07-02T09:15:30","guid":{"rendered":"https:\/\/ruby-doc.org\/blog\/?p=1103"},"modified":"2026-07-02T09:15:31","modified_gmt":"2026-07-02T09:15:31","slug":"building-a-crypto-exchange-api-client-in-ruby-rest-websocket","status":"publish","type":"post","link":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/","title":{"rendered":"Building a Crypto Exchange API Client in Ruby (REST + WebSocket)"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Sooner or later, every Ruby developer involved in fintech or crypto faces the same challenge: tweaking an exchange&#8217;s API. Sometimes it&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In this article, we&#8217;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.&nbsp;<strong>WebSocket&nbsp;<\/strong>is a persistent connection through which the server automatically sends order book, ticker, and trade updates as they occur. Now, let&#8217;s get hands-on.<\/p>\n\n\n\n<h1 class=\"wp-block-heading\">Selecting tools (Ruby setup)<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">For REST requests, Faraday is sufficient \u2014it&#8217;s more convenient than bare net\/http due to its middleware and readable syntax. For WebSockets, we&#8217;ll use faye-websocket over EventMachine \u2014a proven and well-documented combination. Request signing will be done using the standard openssl, <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/JavaScript\/Reference\/Global_Objects\/JSON\/parse\">and JSON parsing will be done<\/a> using the built-in json.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Binance&#8217;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&#8217;s perspective \u2014 how order matching, balances, and market data feeds are actually implemented \u2014 it&#8217;s worth studying the codebase of an&nbsp;<a href=\"https:\/\/merehead.com\/blog\/create-open-source-crypto-exchange\/\">open source cryptocurrency exchange<\/a>; it makes the architecture we&#8217;re about to build in Ruby much easier to reason about.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><code>ruby<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code># Gemfile<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>source \"https:\/\/rubygems.org\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>gem \"faraday\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>gem \"faye-websocket\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>gem \"eventmachine\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>gem \"json\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>gem \"dotenv\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>We keep the file structure simple: one class for REST, one for WebSocket, and a common config:<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>lib\/<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>exchange_client\/<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>config.rb<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>rest_client.rb<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ws_client.rb<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>exchange_client.rb<\/code><\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">This separation allows you to test the REST and WS parts independently and not mix synchronous and asynchronous code in the same class.<\/p>\n\n\n\n<h1 class=\"wp-block-heading\">Connecting to the REST API<\/h1>\n\n\n\n<h2 class=\"wp-block-heading\">Public endpoints<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The simplest case is data that doesn&#8217;t require authorization: the current price, order book, or a list of trading pairs. This is a standard GET request.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><code>ruby<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>require Faraday<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>require \"json\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>class ExchangeClient :: RestClient<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; BASE_URL = \"https:\/\/api.binance.com\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; def initialize<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; @conn = Faraday. new ( url : BASE_URL ) do |f|<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>f. request :url_encoded<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>f. adapterFaraday. default_adapter<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; def ticker ( symbol )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>response = @conn. get ( \"\/api\/v3\/ticker\/price\", symbol : symbol )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; JSON. parse ( response.body )\u200b\u200b<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>end<\/code><\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">The method sends a GET to a public endpoint, passes the pair symbol as a parameter, and parses the exchange&#8217;s response into a Ruby hash. No keys are required here\u2014this is open market data.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Authenticated requests<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">For private endpoints\u2014to retrieve account data (including balances), Binance requires HMAC-SHA256 of the parameter string plus the header with the public key.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><code>ruby<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>require \"openssl\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>class ExchangeClient :: RestClient<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; def initialize ( api_key : ENV. fetch ( \"EXCHANGE_API_KEY\" ),<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; api_secret : ENV. fetch ( \"EXCHANGE_API_SECRET\" ))<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; @api_key = api_key<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; @api_secret = api_secret<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; @conn = Faraday. new ( url : BASE_URL )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; def sign ( params )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>query = URI. encode_www_form ( params )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>signature = OpenSSL :: HMAC. hexdigest ( \"SHA256\", @api_secret, query )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>query + \"&amp;signature= #{ signature } \"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; def balances<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>params = { timestamp : ( Time. now. to_f * 1000 ). to_i }<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>signed_query = sign ( params )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>response = @conn. get ( \"\/api\/v3\/account? #{ signed_query } \" ) do |req|<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>req. headers [ \"X-MBX-APIKEY\" ] = @api_key<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; JSON. parse ( response.body )\u200b\u200b<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>end<\/code><\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">An important security point: keys are always read from environment variables via ENV.fetch, not hardcoded. Use dotenv locally and environment secrets in production\u2014hardcoded keys in the repository will leak sooner or later.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Placing and canceling an order<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With a signature in hand, adding and canceling an order is a matter of a couple of methods.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><code>ruby<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>def place_order ( symbol :, side :, quantity :, price : )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>params = {<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; symbol : symbol, side : side, type : \"LIMIT\",<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; timeInForce : \"GTC\", quantity : quantity, price : price,<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; timestamp : ( Time. now. to_f * 1000 ). to_i<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; }<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>signed_query = sign ( params )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>response = @conn. post ( \"\/api\/v3\/order? #{ signed_query } \" ) do |req|<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>req. headers [ \"X-MBX-APIKEY\" ] = @api_key<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; JSON. parse ( response.body )\u200b\u200b<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>def cancel_order ( symbol :, order_id : )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>params = { symbol : symbol, orderId : order_id,<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; timestamp : ( Time. now. to_f * 1000 ). to_i }<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>signed_query = sign ( params )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>response = @conn. delete ( \"\/api\/v3\/order? #{ signed_query } \" ) do |req|<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>req. headers [ \"X-MBX-APIKEY\" ] = @api_key<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; JSON. parse ( response.body )\u200b\u200b<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>end<\/code><\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">Both methods reuse the familiar sign method ; only the set of parameters and the HTTP verb differ. The exchange&#8217;s response should always be analyzed for the error field\u2014more on that below.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Streaming data via WebSocket<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">REST is good for one-time requests, but for a real-time order book or feed, <a href=\"https:\/\/blog.postman.com\/how-do-websockets-work\/\">you need<\/a> a WebSocket\u2014a persistent connection to which the exchange automatically pushes updates.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><code>ruby<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>require \"faye\/websocket\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>require \"eventmachine\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>require \"json\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>class ExchangeClient :: WsClient<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; WS_URL = \"wss:\/\/stream.binance.com:9443\/ws\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; def subscribe ( symbol )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>@symbol = symbol<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; EM. run do<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ws = Faye :: WebSocket :: Client. new ( \" #{ WS_URL } \/ #{ symbol. downcase } @trade\" )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ws. on :open do<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>puts \"WS connected to #{ symbol } \"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ws. on :message do |event|<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>data = JSON. parse ( event.data )\u200b\u200b<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>handle_trade ( data )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ws. on :close do |event|<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>puts \"WS closed: #{ event. code } #{ event. reason } \"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; def handle_trade ( data )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; # updating the local state of the order book\/price<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; @last_price = data [ \"p\" ]<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>end<\/code><\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">Here, we connect to the trade stream for a specific pair, parse the JSON for each incoming message, and update the local state\u2014in 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Maintaining the connection<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><code>ruby<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ws. on :ping do |event|<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ws. pong ( event. data )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ws. on :close do |event|<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>puts \"Disconnected, reconnecting...\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>reconnect_with_backoff<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>def reconnect_with_backoff ( attempt = 1 )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; delay = [ 2** attempt, 30 ].min<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; EM. add_timer ( delay ) { subscribe ( @symbol ) }<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>end<\/code><\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h1 class=\"wp-block-heading\">Error handling, request limits, and resilience<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Exchanges return standard HTTP codes: 429 indicates a request limit exceeded, 5xx indicates a problem on the exchange&#8217;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.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><code>ruby<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>def with_retries ( max_attempts : 5 )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>attempt = 0<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; begin<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>attempt += 1<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; yield<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; rescue Faraday::TimeoutError =&gt; e<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; if attempt &lt; max_attempts<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>sleep ( 2 **attempt )<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; retry<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; else<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; raise e<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp;&nbsp;&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; end<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>end<\/code><\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">Order idempotency is a separate issue. If an order request has been sent but the response hasn&#8217;t arrived (timed out), resubmitting it can create a duplicate. Most exchanges use a clientOrderId for this purpose \u2014 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&#8217;s response\u2014this greatly simplifies incident investigation and reconciliation of trades retroactively.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Timeouts on the HTTP client should be explicitly set, rather than relying on library defaults\u2014a frozen connection without a timeout can hold your bot&#8217;s thread for hours.<\/p>\n\n\n\n<h1 class=\"wp-block-heading\">Putting it all together<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><code>ruby<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>require_relative \"lib\/exchange_client\"<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>rest = ExchangeClient :: RestClient. new<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>puts rest. balances<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>order = rest. place_order (<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>&nbsp; symbol : \"BTCUSDT\", side : \"BUY\", quantity : 0.001, price : 60,000<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>)<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>puts order<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ws = ExchangeClient :: WsClient. new<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ws. subscribe ( \"BTCUSDT\" )<\/code><\/p>\n<\/blockquote>\n\n\n\n<h1 class=\"wp-block-heading\">Conclusion<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">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 &#8211; from a simple dashboard to a full-fledged trading bot.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Sooner or later, every Ruby developer involved in fintech or crypto faces the same challenge: tweaking an exchange&#8217;s API. Sometimes it&#8217;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 [&hellip;]<\/p>\n","protected":false},"author":2,"featured_media":1106,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-1103","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ruby-tips"],"blocksy_meta":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.0 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Building a Crypto Exchange API Client in Ruby (REST + WebSocket) - Ruby-Doc.org<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Building a Crypto Exchange API Client in Ruby (REST + WebSocket) - Ruby-Doc.org\" \/>\n<meta property=\"og:description\" content=\"Sooner or later, every Ruby developer involved in fintech or crypto faces the same challenge: tweaking an exchange&#8217;s API. Sometimes it&#8217;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 [&hellip;]\" \/>\n<meta property=\"og:url\" content=\"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/\" \/>\n<meta property=\"og:site_name\" content=\"Ruby-Doc.org\" \/>\n<meta property=\"article:published_time\" content=\"2026-07-02T09:15:30+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-07-02T09:15:31+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/ruby-doc.org\/blog\/wp-content\/uploads\/2026\/07\/Building-a-Crypto-Exchange-API-Client-in-Ruby.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1536\" \/>\n\t<meta property=\"og:image:height\" content=\"1024\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"Ryan McGregor\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Ryan McGregor\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"5 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/\"},\"author\":{\"name\":\"Ryan McGregor\",\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/#\\\/schema\\\/person\\\/db7fcc3c518c40f29f8bf79ffa678dfc\"},\"headline\":\"Building a Crypto Exchange API Client in Ruby (REST + WebSocket)\",\"datePublished\":\"2026-07-02T09:15:30+00:00\",\"dateModified\":\"2026-07-02T09:15:31+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/\"},\"wordCount\":991,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/#organization\"},\"image\":{\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/Building-a-Crypto-Exchange-API-Client-in-Ruby.png\",\"articleSection\":[\"Ruby tips\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/\",\"url\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/\",\"name\":\"Building a Crypto Exchange API Client in Ruby (REST + WebSocket) - Ruby-Doc.org\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/Building-a-Crypto-Exchange-API-Client-in-Ruby.png\",\"datePublished\":\"2026-07-02T09:15:30+00:00\",\"dateModified\":\"2026-07-02T09:15:31+00:00\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/#primaryimage\",\"url\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/Building-a-Crypto-Exchange-API-Client-in-Ruby.png\",\"contentUrl\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/Building-a-Crypto-Exchange-API-Client-in-Ruby.png\",\"width\":1536,\"height\":1024},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Building a Crypto Exchange API Client in Ruby (REST + WebSocket)\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/\",\"name\":\"Ruby-Doc.org\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/#organization\",\"name\":\"Ruby-Doc.org\",\"url\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/wp-content\\\/uploads\\\/2025\\\/07\\\/Ruby-Doc.org_logo_cropped.png\",\"contentUrl\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/wp-content\\\/uploads\\\/2025\\\/07\\\/Ruby-Doc.org_logo_cropped.png\",\"width\":909,\"height\":833,\"caption\":\"Ruby-Doc.org\"},\"image\":{\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\"}},{\"@type\":\"Person\",\"@id\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/#\\\/schema\\\/person\\\/db7fcc3c518c40f29f8bf79ffa678dfc\",\"name\":\"Ryan McGregor\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/f7b4d11da7f55d40163cd9431935ce1148d9bd69c95928064822f7757b6314dd?s=96&d=mm&r=g\",\"url\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/f7b4d11da7f55d40163cd9431935ce1148d9bd69c95928064822f7757b6314dd?s=96&d=mm&r=g\",\"contentUrl\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/f7b4d11da7f55d40163cd9431935ce1148d9bd69c95928064822f7757b6314dd?s=96&d=mm&r=g\",\"caption\":\"Ryan McGregor\"},\"url\":\"https:\\\/\\\/ruby-doc.org\\\/blog\\\/author\\\/ryan\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Building a Crypto Exchange API Client in Ruby (REST + WebSocket) - Ruby-Doc.org","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/","og_locale":"en_US","og_type":"article","og_title":"Building a Crypto Exchange API Client in Ruby (REST + WebSocket) - Ruby-Doc.org","og_description":"Sooner or later, every Ruby developer involved in fintech or crypto faces the same challenge: tweaking an exchange&#8217;s API. Sometimes it&#8217;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 [&hellip;]","og_url":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/","og_site_name":"Ruby-Doc.org","article_published_time":"2026-07-02T09:15:30+00:00","article_modified_time":"2026-07-02T09:15:31+00:00","og_image":[{"width":1536,"height":1024,"url":"https:\/\/ruby-doc.org\/blog\/wp-content\/uploads\/2026\/07\/Building-a-Crypto-Exchange-API-Client-in-Ruby.png","type":"image\/png"}],"author":"Ryan McGregor","twitter_card":"summary_large_image","twitter_misc":{"Written by":"Ryan McGregor","Est. reading time":"5 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/#article","isPartOf":{"@id":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/"},"author":{"name":"Ryan McGregor","@id":"https:\/\/ruby-doc.org\/blog\/#\/schema\/person\/db7fcc3c518c40f29f8bf79ffa678dfc"},"headline":"Building a Crypto Exchange API Client in Ruby (REST + WebSocket)","datePublished":"2026-07-02T09:15:30+00:00","dateModified":"2026-07-02T09:15:31+00:00","mainEntityOfPage":{"@id":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/"},"wordCount":991,"commentCount":0,"publisher":{"@id":"https:\/\/ruby-doc.org\/blog\/#organization"},"image":{"@id":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/#primaryimage"},"thumbnailUrl":"https:\/\/ruby-doc.org\/blog\/wp-content\/uploads\/2026\/07\/Building-a-Crypto-Exchange-API-Client-in-Ruby.png","articleSection":["Ruby tips"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/","url":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/","name":"Building a Crypto Exchange API Client in Ruby (REST + WebSocket) - Ruby-Doc.org","isPartOf":{"@id":"https:\/\/ruby-doc.org\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/#primaryimage"},"image":{"@id":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/#primaryimage"},"thumbnailUrl":"https:\/\/ruby-doc.org\/blog\/wp-content\/uploads\/2026\/07\/Building-a-Crypto-Exchange-API-Client-in-Ruby.png","datePublished":"2026-07-02T09:15:30+00:00","dateModified":"2026-07-02T09:15:31+00:00","breadcrumb":{"@id":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/#primaryimage","url":"https:\/\/ruby-doc.org\/blog\/wp-content\/uploads\/2026\/07\/Building-a-Crypto-Exchange-API-Client-in-Ruby.png","contentUrl":"https:\/\/ruby-doc.org\/blog\/wp-content\/uploads\/2026\/07\/Building-a-Crypto-Exchange-API-Client-in-Ruby.png","width":1536,"height":1024},{"@type":"BreadcrumbList","@id":"https:\/\/ruby-doc.org\/blog\/building-a-crypto-exchange-api-client-in-ruby-rest-websocket\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/ruby-doc.org\/blog\/"},{"@type":"ListItem","position":2,"name":"Building a Crypto Exchange API Client in Ruby (REST + WebSocket)"}]},{"@type":"WebSite","@id":"https:\/\/ruby-doc.org\/blog\/#website","url":"https:\/\/ruby-doc.org\/blog\/","name":"Ruby-Doc.org","description":"","publisher":{"@id":"https:\/\/ruby-doc.org\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/ruby-doc.org\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/ruby-doc.org\/blog\/#organization","name":"Ruby-Doc.org","url":"https:\/\/ruby-doc.org\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/ruby-doc.org\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/ruby-doc.org\/blog\/wp-content\/uploads\/2025\/07\/Ruby-Doc.org_logo_cropped.png","contentUrl":"https:\/\/ruby-doc.org\/blog\/wp-content\/uploads\/2025\/07\/Ruby-Doc.org_logo_cropped.png","width":909,"height":833,"caption":"Ruby-Doc.org"},"image":{"@id":"https:\/\/ruby-doc.org\/blog\/#\/schema\/logo\/image\/"}},{"@type":"Person","@id":"https:\/\/ruby-doc.org\/blog\/#\/schema\/person\/db7fcc3c518c40f29f8bf79ffa678dfc","name":"Ryan McGregor","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/secure.gravatar.com\/avatar\/f7b4d11da7f55d40163cd9431935ce1148d9bd69c95928064822f7757b6314dd?s=96&d=mm&r=g","url":"https:\/\/secure.gravatar.com\/avatar\/f7b4d11da7f55d40163cd9431935ce1148d9bd69c95928064822f7757b6314dd?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/f7b4d11da7f55d40163cd9431935ce1148d9bd69c95928064822f7757b6314dd?s=96&d=mm&r=g","caption":"Ryan McGregor"},"url":"https:\/\/ruby-doc.org\/blog\/author\/ryan\/"}]}},"_links":{"self":[{"href":"https:\/\/ruby-doc.org\/blog\/wp-json\/wp\/v2\/posts\/1103","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/ruby-doc.org\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/ruby-doc.org\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/ruby-doc.org\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/ruby-doc.org\/blog\/wp-json\/wp\/v2\/comments?post=1103"}],"version-history":[{"count":2,"href":"https:\/\/ruby-doc.org\/blog\/wp-json\/wp\/v2\/posts\/1103\/revisions"}],"predecessor-version":[{"id":1105,"href":"https:\/\/ruby-doc.org\/blog\/wp-json\/wp\/v2\/posts\/1103\/revisions\/1105"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/ruby-doc.org\/blog\/wp-json\/wp\/v2\/media\/1106"}],"wp:attachment":[{"href":"https:\/\/ruby-doc.org\/blog\/wp-json\/wp\/v2\/media?parent=1103"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/ruby-doc.org\/blog\/wp-json\/wp\/v2\/categories?post=1103"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/ruby-doc.org\/blog\/wp-json\/wp\/v2\/tags?post=1103"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}