To get Amazon product data in Ruby, send one GET request with Net::HTTP to /api/amazon-product-lookup with an ASIN, a marketplace ID and your API key in the X-Api-Key header, then JSON.parse the body. No gem is needed. This quickstart builds a small client for lookups and keyword search, adds a retry policy that never pays twice for the same miss, runs bulk lookups on threads, and shows where the client belongs in a Rails app. Older Ruby gems wrap Amazon's Product Advertising API, which is retired - see the PA-API alternative guide.
Key Takeaways
- The standard library is enough: Net::HTTP, JSON, URI, Queue and Thread. The code runs on Ruby 2.6 and later.
- Set read_timeout to 120 seconds; the API allows a scrape that long, and a request you abandon is still billed.
- Retry 429, 500, 502 and 503 only. A 404 means the ASIN is not on that marketplace; retrying it buys the same answer again.
- Net::HTTP is not thread-safe and re-sends a timed-out GET by default: one client per thread, and max_retries = 0.
- In Rails, call the API from a background job, never inside a web request that could wait up to 120 seconds.
What do I need before I start?
- Ruby 2.6 or later - the version macOS ships is enough.
- An API key - a free account includes 150 credits, one per request.
- The key in an environment variable, or in Rails credentials, so it never lands in your repository.
Check Ruby and put the key in the environment
ruby -v # ruby 2.6 or later
export SELLERMAGNET_API_KEY="your-key-here"
ruby seller_magnet.rb # one lookup, one credit
How do I get Amazon product data in Ruby?
Wrap one keep-alive Net::HTTP connection in a small client class. get raises a single ApiError carrying the status, the API's own message and any Retry-After, so callers only ever see data or that exception. The lookup method picks the fields most applications need; max_retries = 0 matters: by default Net::HTTP silently re-sends a GET that timed out, and every send is billed. marketplaceId takes one of 23 marketplace IDs, such as ATVPDKIKX0DER for amazon.com.
seller_magnet.rb - a standard-library client with one exception type
require "json"
require "net/http"
require "openssl"
require "uri"
module SellerMagnet
API = URI("https://sellermagnet-api.com/api/")
class ApiError < StandardError
attr_reader :status, :retry_after
def initialize(status, message, retry_after = 0.0)
super("#{status}: #{message}")
@status = status
@retry_after = retry_after
end
end
# One keep-alive connection per client. Net::HTTP is not thread-safe:
# give every thread its own client.
class Client
def initialize(key = ENV.fetch("SELLERMAGNET_API_KEY"))
@key = key
@http = Net::HTTP.new(API.host, API.port)
@http.use_ssl = API.scheme == "https"
@http.open_timeout = 10
@http.read_timeout = 120 # the API allows a scrape up to 120 s
@http.max_retries = 0 # Net::HTTP would silently re-send a timed-out GET; every send is billed
end
def get(endpoint, params)
uri = URI.join(API, endpoint)
uri.query = URI.encode_www_form(params)
request = Net::HTTP::Get.new(uri)
request["X-Api-Key"] = @key
@http.start unless @http.started?
response = @http.request(request)
body = JSON.parse(response.body.to_s) rescue {} # an HTML error page is not JSON
body = {} unless body.is_a?(Hash)
return body["data"] if response.code == "200" && body["success"]
raise ApiError.new(response.code.to_i, body.fetch("message", "request failed"), response["Retry-After"].to_f)
end
def lookup(asin, marketplace_id)
p = get("amazon-product-lookup", asin: asin, marketplaceId: marketplace_id)["productInfo"]
price = p.dig("buyBoxInfo", "price")
{
asin: p["asin"],
title: p["title"],
price: price.is_a?(Numeric) ? price : nil, # "N/A" = no featured offer
currency: p.dig("buyBoxInfo", "currencyCode"),
url: p["link"]
}
end
end
end
if $PROGRAM_NAME == __FILE__
p SellerMagnet::Client.new.lookup("B0CL61F39H", "ATVPDKIKX0DER")
end
Trimmed lookup response (example values)
{
"success": true,
"data": {
"productInfo": {
"asin": "B0CL61F39H",
"title": "PlayStation®5 console (slim)",
"buyBoxInfo": {"price": 444.99, "currencyCode": "USD", "sellerId": "Amazon", "amazonSellerId": "ATVPDKIKX0DER"},
"link": "https://www.amazon.com/dp/B0CL61F39H"
}
}
}
buyBoxInfo.price is a decimal number, or the string "N/A" when no offer is featured - the client turns that into nil instead of zero. Every field is listed on the product lookup page.
How do I search Amazon by keyword in Ruby?
/api/amazon-search takes q, a marketplace ID and an optional count of up to 50. Results come from the first results page, sponsored ones included and flagged, and products without a displayed price are skipped. The price is a string, so parse it with BigDecimal rather than Float.
search.rb - organic results with exact prices
require "bigdecimal"
require_relative "seller_magnet"
client = SellerMagnet::Client.new
count = 20
raise ArgumentError, "count above 50 is a billed 400" unless (1..50).cover?(count)
data = client.get("amazon-search", q: "usb c charger", marketplaceId: "A1PA6795UKMFR9", count: count)
organic = data["searchResults"].reject { |r| r["sponsored"] }.map do |r|
total = r.dig("listingPrice", "price", "total") # a string such as "19.99"
{ position: r["position"], asin: r["asin"], title: r["productTitle"], price: total && BigDecimal(total) }
end
organic.first(5).each do |row|
puts [row[:position], row[:asin], row[:price] && row[:price].to_s("F"), row[:title].to_s[0, 60]].join(" ")
end
Which errors should a Ruby client retry?
Retry only what can succeed on a second try. A 429 from the per-key concurrency cap means it stayed full for 20 seconds; it is free and carries Retry-After: 2. A 500 is our internal error and its credit is refunded, so one retry costs nothing. 502 and 503 are billed per attempt, so wait at least the Retry-After and cap the attempts. A missing parameter is a free 400, but an endpoint's own 400 - such as a count above 50 - is billed. Add the retry helper to the bottom of seller_magnet.rb.
| Status | Meaning | Charged | Client action |
|---|---|---|---|
200 | Success | Yes | Use data |
400 | Bad or missing parameter | Only an endpoint's own 400 | Fix the call |
401 / 403 | Key or credits | No | Stop |
404 | Not on this marketplace | Yes | Store as missing |
429 | Concurrency cap | No | Wait retry-after |
500 | Internal error | No - refunded | Retry once |
502 / 503 | Upstream / breaker | Yes | Wait, then retry |
seller_magnet.rb (continued) - retries only where a second attempt can help
module SellerMagnet
RETRYABLE = [429, 500, 502, 503].freeze
NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, EOFError, Errno::ECONNRESET, Errno::ECONNREFUSED,
Errno::EPIPE, OpenSSL::SSL::SSLError, SocketError].freeze
# Retries only what a second attempt can fix: 429 is free, a 500 is refunded
# (retried once), 502 and 503 are billed per attempt.
def self.with_retry(attempts: 4)
internal_errors = 0
(1..attempts).each do |attempt|
begin
return yield
rescue ApiError => e
internal_errors += 1 if e.status == 500
raise if !RETRYABLE.include?(e.status) || attempt == attempts || internal_errors > 1
sleep([2**attempt + rand, e.retry_after].max)
rescue *NETWORK_ERRORS
raise if attempt == attempts
sleep(2**attempt)
end
end
end
end

How do I run many lookups in parallel?
Use a Queue and a handful of threads. Ruby releases the global VM lock while a thread waits on the network, so threads give real concurrency for API calls. Each thread builds its own client, because one Net::HTTP connection must not be shared. Five workers stay well under the per-key concurrency cap; there is no batch endpoint, and the bulk ASIN lookup guide covers pacing for thousands of products.
bulk.rb - five workers, one client each, results to CSV
require "csv"
require_relative "seller_magnet"
products = [%w[B0CL61F39H ATVPDKIKX0DER], %w[B0CLTBHXWQ APJ6JRA9NG5V4]] # [asin, marketplaceId]
jobs = Queue.new
products.each { |pair| jobs << pair }
rows = Queue.new
workers = Array.new(5) do
Thread.new do
client = SellerMagnet::Client.new # one connection per thread
loop do
asin, marketplace_id = begin
jobs.pop(true)
rescue ThreadError # queue empty
break
end
rows << begin
SellerMagnet.with_retry { client.lookup(asin, marketplace_id) }.merge(error: nil)
rescue SellerMagnet::ApiError => e
{ asin: asin, error: e.status }
rescue StandardError => e # network gave up after retries: keep the batch
{ asin: asin, error: e.class.name }
end
end
end
end
workers.each(&:join)
CSV.open("products.csv", "w") do |csv|
csv << %w[asin title price currency error]
csv << rows.pop.values_at(:asin, :title, :price, :currency, :error) until rows.empty?
end

How do I use the client in Rails?
Save the client as lib/seller_magnet.rb: Zeitwerk maps that file name to SellerMagnet, and config.autoload_lib, present in apps generated by Rails 7.1 and later, loads it; older apps require "seller_magnet" in an initializer. Keep the key in Rails credentials and pass it in with SellerMagnet::Client.new(Rails.application.credentials.dig(:sellermagnet, :api_key)). Call it from a background job (Solid Queue, Sidekiq or GoodJob via Active Job), not from a controller: a lookup can take up to 120 seconds. Rescue ApiError for 400, 401, 403 and 404 inside the job, or use discard_on, and store the status - otherwise the job runner's own retries (Sidekiq defaults to 25) pay for the same 404 again. The error-handling guide covers the per-IP limit and the circuit breaker.
Frequently Asked Questions
Is there a Ruby gem for the SellerMagnet API?
No, and none is needed. Every endpoint is a plain GET with query parameters, so Net::HTTP and JSON from the standard library cover it.
Which Ruby version do I need?
Ruby 2.6 or later. On Ruby 3.4 and later, csv and bigdecimal are bundled gems, so list them in your Gemfile when you use Bundler.
What timeout should the client use?
A read_timeout of 120 seconds. The server allows a scrape that long, and a request the client abandons early is still charged.
Can threads share one client?
No. Net::HTTP connections are not thread-safe, so create one client per thread, as bulk.rb does.
Should I call the API inside a Rails controller?
No. Run it in a background job and store the result; a single lookup can take up to 120 seconds.
Bottom line: one Net::HTTP client with a 120-second read timeout, one exception type, retries for 429, 500, 502 and 503 only, and a thread per worker. The same pattern exists for Python and Node.js, and every endpoint is in the API documentation.