To call an Amazon product API from Elixir, send a GET request with the built-in :httpc client to /api/amazon-product-lookup with an ASIN, a marketplace ID and your key in the x-api-key header, then decode the JSON body with Jason. This quickstart needs no HTTP dependency at all (:httpc ships with Erlang/OTP), builds one client for three endpoints (lookup, offers and statistics), keeps the key out of every URL, and retries only the errors a second attempt can fix.
Key Takeaways
:httpcis part of the:inetsapplication that ships with Erlang/OTP. Add:inets,:ssland:public_keytoextra_applicationsin mix.exs, or mix warns that they are used but not declared.- Send the key in the x-api-key header, never in the URL as api_key: a stale key left in a URL overrides a valid header when both are present, and URLs end up in logs.
- Lookup sends the price as a number or the string "N/A". Decode it so a missing price becomes nil, never 0.0 and never a crash.
- Elixir's Regex (PCRE)
$matches the end of the string OR right before a trailing newline, even without a multiline flag.~r/^[A-Z0-9]{10}$/lets "B0CL61F39H\n" through;~r/\A[A-Z0-9]{10}\z/does not. - Retry 429, 500 once, 502 and 503. A 404 is an answer: it is billed once, and a retry pays for the same answer again.
Prerequisites: Elixir 1.14+ and an API key
- Elixir 1.14 or later with Erlang/OTP (
elixir --version). No separate HTTP library::httpcships with OTP's:inetsapplication. - An API key: a free account includes 150 credits after e-mail verification, one credit per request.
- The key in the
SELLERMAGNET_API_KEYenvironment variable, so it never lands in your repository.
Create the project, add one dependency, set the key
elixir --version # Elixir 1.14+, OTP ships :inets/:ssl/:public_key
mix new amazon_elixir --app amazon_elixir && cd amazon_elixir
# add {:jason, "~> 1.4"} to deps() in mix.exs, and :inets/:ssl/:public_key to extra_applications
mix local.hex --force && mix deps.get
export SELLERMAGNET_API_KEY="your-key-here"
# save lib/sellermagnet/client.ex, retry.ex and product.ex from this post, then:
mix run main.exs # at most 3 credits per product, see the credit math below
mix.exs - Jason for JSON, :inets/:ssl/:public_key declared
defmodule Amazon.MixProject do
use Mix.Project
def project do
[
app: :amazon_elixir,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
elixirc_paths: elixirc_paths(Mix.env()),
deps: deps()
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger, :inets, :ssl, :public_key]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:jason, "~> 1.4"}
]
end
end
How do I call the Amazon product API from Elixir?
:httpc needs :inets and :ssl started once (Application.ensure_all_started/1 or the explicit :inets.start() / :ssl.start() calls below); after that one process can issue any number of requests, since :httpc manages its own connection pool internally. The key goes into a header on every call, never into the query string: when a request carries both an api_key parameter and the header, the query parameter wins, so a stale key left in a URL silently overrides a valid header, and a URL with a key ends up in logs and error messages. The header vs query string guide shows where it leaks.
lib/sellermagnet/client.ex - one request, one decoded envelope
defmodule SellerMagnet.Client do
@moduledoc """
One client, the key in the x-api-key header, a 120 s timeout, one decoded envelope.
Every JSON body is `{"success": ..., "data": ...}` on success or carries a `message` on failure.
"""
defstruct [:base, :key, billed: 0]
@default_base "https://sellermagnet-api.com/api"
@spec new(keyword()) :: %__MODULE__{}
def new(opts \\ []) do
:inets.start()
:ssl.start()
key = Keyword.get(opts, :key) || System.fetch_env!("SELLERMAGNET_API_KEY")
base = Keyword.get(opts, :base) || System.get_env("SELLERMAGNET_API_BASE", @default_base)
%__MODULE__{base: base, key: key}
end
@doc "One GET, no retry. Returns {:ok, data} or {:error, reason}."
def get_once(%__MODULE__{} = c, path, params) do
qs = URI.encode_query(params)
url = String.to_charlist("#{c.base}#{path}?#{qs}")
headers = [{~c"x-api-key", String.to_charlist(c.key)}]
http_opts = [timeout: 120_000, connect_timeout: 120_000] ++ ssl_opts(c.base)
case :httpc.request(:get, {url, headers}, http_opts, body_format: :binary) do
{:ok, {{_proto, status, _reason}, resp_headers, body}} ->
decode(status, body, resp_headers)
{:error, reason} ->
{:error, {:http, reason}}
end
end
# Only add TLS verification options over https; the local test harness runs over
# plain http on 127.0.0.1 and has no certificate to verify.
defp ssl_opts(base) do
if String.starts_with?(base, "https") do
[
ssl: [
verify: :verify_peer,
cacerts: :public_key.cacerts_get(),
depth: 3,
customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)]
]
]
else
[]
end
end
defp decode(200, body, _headers) do
case Jason.decode(body) do
{:ok, %{"data" => data}} -> {:ok, data}
{:ok, other} -> {:error, {:api, 200, "200 with no data field: #{inspect(other)}", nil}}
{:error, _} -> {:error, {:api, 200, "200 with a non-JSON body", nil}}
end
end
defp decode(status, body, headers) do
retry_after = retry_after(headers)
message =
case Jason.decode(body) do
{:ok, %{"message" => m}} -> m
{:ok, %{"error" => m}} -> m
# A 429 from the per-IP limit returns an HTML page, not JSON; still an answer.
_ -> "non-JSON body"
end
{:error, {:api, status, message, retry_after}}
end
defp retry_after(headers) do
headers
|> Enum.find_value(fn {k, v} ->
if String.downcase(to_string(k)) == "retry-after", do: to_string(v)
end)
|> case do
nil -> nil
s -> String.to_integer(s) * 1000
end
end
end
get_once/3 returns {:ok, data} or {:error, {:api, status, message, retry_after}} with the API's own message and any Retry-After, in milliseconds so it drops straight into :timer.sleep/1. Every JSON body is {"success": ..., "data": ...} on success or carries a message on failure. The per-IP limit (600 requests a minute, 15,000 an hour) answers 429 with an HTML page and no Retry-After, which is why a non-JSON body still becomes {:error, {:api, ...}} instead of raising a Jason.DecodeError. TLS verification uses :public_key.cacerts_get/0 (OTP's own trust store, no bundled CA file) only over https; the local test harness in this post talks plain http to 127.0.0.1 and skips it.
How do I decode a price that is a number or "N/A"?
Pattern-match the raw JSON value: keep it only when it is a number, otherwise return nil. productInfo.buyBoxInfo.price is the featured offer's item price without shipping, in decimal units, or the string "N/A" when no offer is featured. Returning nil for anything that is not a number means a missing price can never be read as a free item or a real 0.0, and Jason never raises on it because the field is just a map value, not a struct with a fixed type.
lib/sellermagnet/product.ex - lookup, offers and statistics decoded safely
defmodule SellerMagnet.Product do
@moduledoc """
The three product endpoints, decoded safely: a missing lookup/offers price is nil, never 0.0;
a statistics price stays in its own MinorUnits-shaped tuple so it is never summed with a
decimal price by accident.
"""
alias SellerMagnet.Retry
# \A...\z anchors the WHOLE string, unlike ^...$ which (in Elixir/PCRE, without the D
# modifier) also matches right before a trailing newline. "B0CL61F39H\n" passes ^...$ but
# correctly fails \A...\z. See scenarios in test/sellermagnet_test.exs for the proof.
@asin ~r/\A[A-Z0-9]{10}\z/
@japan_marketplace_id "A1VC38T7YXB528"
def asin?(value), do: is_binary(value) and Regex.match?(@asin, value)
def lookup(client, asin, marketplace_id) do
with {:ok, data} <- Retry.get(client, "/amazon-product-lookup", asin: asin, marketplaceId: marketplace_id) do
info = get_in(data, ["productInfo", "buyBoxInfo"]) || %{}
{:ok, %{price: decimal_or_na(info["price"]), currency: info["currencyCode"]}}
end
end
def offers(client, asin, marketplace_id) do
with {:ok, data} <- Retry.get(client, "/amazon-product-offers", asin: asin, marketplaceId: marketplace_id) do
buy_box = data["buyBox"] || %{}
# buyBox is {} when nobody holds it; sellerId "" means "no featured offer".
featured =
case buy_box["sellerId"] do
nil -> nil
"" -> nil
seller -> %{seller_id: seller, total_price: buy_box["totalPrice"]}
end
offers = data["offers"] || []
{:ok, %{featured: featured, offer_count: length(offers)}}
end
end
def statistics(client, asin, marketplace_id) do
case Retry.get(client, "/amazon-product-statistics", asin: asin, marketplaceId: marketplace_id) do
{:ok, data} ->
cents = minor_or_na(data["buyBoxPrice"])
{:ok, %{minor_units: cents, decimal: to_decimal(cents, marketplace_id), fulfillment: data["buyBoxFulfillment"]}}
# Statistics covers 11 marketplaces; anywhere else it is a free 400, same answer shape.
{:error, {:api, 400, message, _}} ->
{:not_covered, message}
other ->
other
end
end
# "N/A" and null both decode to nil, never to 0.0: a missing price must never look like a
# free item or a real zero-price offer.
defp decimal_or_na(v) when is_number(v), do: v * 1.0
defp decimal_or_na(_), do: nil
defp minor_or_na(v) when is_integer(v), do: v
defp minor_or_na(_), do: nil
# Whole yen on amazon.co.jp (no minor unit); cents, pence or centavos everywhere else.
defp to_decimal(nil, _marketplace_id), do: nil
defp to_decimal(cents, @japan_marketplace_id), do: cents * 1.0
defp to_decimal(cents, _marketplace_id), do: cents / 100.0
end
Trimmed lookup response (example values from the API docs)
{
"success": true,
"data": {
"productInfo": {
"asin": "B0CL61F39H",
"buyBoxInfo": {"price": 444.99, "currencyCode": "USD"}
}
}
}
The offers endpoint returns the live Buy Box and the first page of offers. buyBox is an empty map when nobody wins it, so featured/0 reads its sellerId and treats both nil and "" as "no featured offer". inventory is the add-to-cart maximum, not the seller's stock.
Why is the statistics price 100 times bigger?
Because /amazon-product-statistics reports buyBoxPrice in integer minor units: 41800 means 418.00. Lookup and offers report decimal units. It is also a landed price (item plus shipping, like the offers totalPrice), while the lookup price excludes shipping, so 409.99 from lookup and 41800 from statistics can describe the same offer. to_decimal/2 divides by 100, except on amazon.co.jp, where the values are whole yen. More detail is in the cents vs decimal guide.
| Field | Endpoint | JSON value | Elixir shape |
|---|---|---|---|
productInfo.buyBoxInfo.price | lookup | 444.99 or "N/A" | number | nil |
buyBox | offers | {} when none | map, sellerId "" |
buyBox.totalPrice | offers | not rounded | float, round once |
offers[].totalPrice | offers | 418.00 | float |
offers[].inventory | offers | 30 (cart cap) | integer |
buyBoxPrice | statistics | 41800 or null | integer | nil (minor units) |
buyBoxFulfillment | statistics | "FBM" or null | String | nil |

Retry policy: which errors an Elixir client should retry
Retry only what can succeed on a second try. A 429 from the per-key concurrency cap is free and carries Retry-After: 2. A 500 without Retry-After is refunded, so one retry costs nothing; a second consecutive 500 for the same call is reported, not retried again. 502 and 503 are billed on every attempt, so wait at least the Retry-After and cap the attempts at 4. Statistics covers 11 marketplaces; anywhere else it answers a free 400. A timeout is not retried: the first request may still be running, and it is billed.
| Status | Meaning | Charged | Client action |
|---|---|---|---|
200 | Success | Yes | Decode data |
400 | Bad parameter or uncovered marketplace | Only an endpoint's own 400 | Fix the call |
401 / 403 | Key or credits | No | Stop the run |
404 | Not on this marketplace | Yes | Record, never retry |
429 | Concurrency or per-IP limit | No | Wait Retry-After |
500 | Internal error, no Retry-After | No, refunded | Retry once |
502 / 503 | Retryable error / unavailable | Yes | Wait, retry, cap |
| Timeout | No answer in 120 s | Yes | Report, never resend |
lib/sellermagnet/retry.ex - retries that respect billing
defmodule SellerMagnet.Retry do
@moduledoc """
Retries only an error a second attempt can fix, and never an answer the API already billed.
429 (per-key concurrency cap) is free. A 500 without Retry-After is refunded, so one retry
costs nothing; a second consecutive 500 is reported, not retried again (it would pay twice
for the same failure). 502 and 503 are billed on every attempt, capped at 4 tries total.
400, 401, 403 and 404 are answers: never retried.
"""
@attempts 4
@retryable [429, 500, 502, 503]
@doc "GET with the billing-aware retry policy. sleep_fun is injectable so tests never wait."
def get(client, path, params, sleep_fun \\ &:timer.sleep/1) do
loop(client, path, params, 1, 0, sleep_fun)
end
defp loop(client, path, params, attempt, internal_errors, sleep_fun) do
case SellerMagnet.Client.get_once(client, path, params) do
{:ok, data} ->
{:ok, data}
{:error, {:api, status, _message, _retry_after}} = err when status in @retryable ->
internal_errors = internal_errors + if status == 500, do: 1, else: 0
cond do
# A second 500 for the same call: report it, do not loop forever on a hard failure.
internal_errors > 1 ->
err
attempt == @attempts ->
err
true ->
{:error, {:api, _status, _message, retry_after}} = err
wait = max(backoff(attempt), retry_after || 0)
sleep_fun.(wait)
loop(client, path, params, attempt + 1, internal_errors, sleep_fun)
end
other ->
# 400 / 401 / 403 / 404 or a transport error: an answer, not retried here.
other
end
end
defp backoff(attempt), do: attempt * 500
end
The retry loop is plain recursion with an injectable sleep function, so the tests that prove this policy never actually wait: sleep_fun defaults to :timer.sleep/1 and the test suite passes a function that records the wait and returns immediately instead. For 502 handling on the offers endpoint in particular, see the 502 retry guide.

Run lookup, offers and statistics for five rows
main.exs checks each ASIN's format first with Product.asin?/1, because a malformed ASIN costs a credit on all three endpoints (lookup and offers bill the request, statistics answers a billed 400). It then calls the three endpoints, stops the row after a 401/403 and prints one line per product. A 404 ends its row after one credit, without calling offers and statistics.
main.exs - three endpoints per product, one row each
#!/usr/bin/env elixir
# Demo run: 5 products through lookup + offers + statistics, same shape as the post's example
# output. Run against the fake server so the output is deterministic and costs no real credits:
# SELLERMAGNET_API_BASE=http://127.0.0.1:<port> SELLERMAGNET_API_KEY=test-key elixir main.exs
Code.require_file("lib/sellermagnet/client.ex", __DIR__)
Code.require_file("lib/sellermagnet/retry.ex", __DIR__)
Code.require_file("lib/sellermagnet/product.ex", __DIR__)
alias SellerMagnet.{Client, Product}
products = [
# (ASIN, marketplace ID)
{"B0CL61F39H", "ATVPDKIKX0DER"},
{"B0CLTBHXWQ", "APJ6JRA9NG5V4"},
{"B0CL61F39H", "A2VIGQ35RCS4UG"},
{"B0NOTFOUND", "ATVPDKIKX0DER"},
{"B0CL61F39", "ATVPDKIKX0DER"}
]
client = Client.new()
IO.puts(
String.pad_trailing("asin", 12) <>
String.pad_trailing("marketplace", 16) <>
String.pad_trailing("lookup", 12) <>
String.pad_trailing("offers buy box", 32) <>
"statistics"
)
billed = :counters.new(1, [])
row = fn asin, marketplace ->
if not Product.asin?(asin) do
"not an ASIN, skipped (0 credits)"
else
case Product.lookup(client, asin, marketplace) do
{:ok, l} ->
:counters.add(billed, 1, 1)
lookup_str = if l.price, do: "#{l.price} #{l.currency}", else: "N/A"
case Product.offers(client, asin, marketplace) do
{:ok, o} ->
:counters.add(billed, 1, 1)
offers_str =
case o.featured do
nil -> "none, no new offers"
f -> "#{f.total_price} #{f.seller_id}"
end
stats_str =
case Product.statistics(client, asin, marketplace) do
{:ok, s} ->
:counters.add(billed, 1, 1)
"#{s.minor_units} = #{s.decimal} #{s.fulfillment}"
{:not_covered, _} ->
"not covered (free 400)"
{:error, {:api, status, msg, _}} ->
"#{status}: #{msg}"
end
"#{lookup_str} #{String.pad_trailing(offers_str, 30)}#{stats_str}"
{:error, {:api, status, msg, _}} ->
"#{status}: #{msg}"
end
{:error, {:api, 404, msg, _}} ->
:counters.add(billed, 1, 1)
"404: #{msg}"
{:error, {:api, status, msg, _}} ->
"#{status}: #{msg}"
end
end
end
for {asin, marketplace} <- products do
line = row.(asin, marketplace)
IO.puts(String.pad_trailing(asin, 12) <> String.pad_trailing(marketplace, 16) <> line)
end
IO.puts("credits charged: #{:counters.get(billed, 1)}")
Program output (illustrative data; credits are counted, not re-fetched)
asin marketplace lookup offers buy box statistics
B0CL61F39H ATVPDKIKX0DER 444.99 USD 444.99 A3853PJW50SJG8 44499 = 444.99 FBA
B0CLTBHXWQ APJ6JRA9NG5V4 409.99 EUR 418.0 A2I59UVTUWUFH0 41800 = 418.0 FBM
B0CL61F39H A2VIGQ35RCS4UG N/A none, no new offers not covered (free 400)
B0NOTFOUND ATVPDKIKX0DER 404: Invalid product
B0CL61F39 ATVPDKIKX0DER not an ASIN, skipped (0 credits)
credits charged: 9
The credit math for that run: 12 requests, 9 credits. Three answers cost nothing: the 429, the 500 (refunded) and the statistics 400 on amazon.ae. The 404 cost one credit and ended its row, and the 9-character ASIN never left the program. A 502 or 503 would have cost a credit on every attempt. Note the second row: lookup says 409.99 (item price), offers and statistics say 418.00 (item plus 8.01 shipping, and 41800 in cents). To test changes without spending credits, point SELLERMAGNET_API_BASE at a local mock server (this post's own test suite runs a real :gen_tcp-based one) instead of the real httc/api default.
Frequently Asked Questions
Is there an Elixir SDK for the SellerMagnet API?
No, and none is needed. Every endpoint is a plain GET with query parameters and a JSON body, so :httpc for HTTP and Jason for decoding cover it.
Do I need an HTTP client library in Elixir?
No. :httpc is part of the :inets application that ships with Erlang/OTP, so a mix project needs zero HTTP dependencies, only extra_applications: [:inets, :ssl, ":public_key] in mix.exs and Jason for JSON.
What timeout should an Elixir client use?
120 seconds (timeout: 120_000 in :httpc's HTTPOptions). The API can hold a request open that long, and a request the client abandons is still charged.
Why does "B0CL61F39H\n" pass a ^...$ ASIN check?
Because in Elixir's Regex (PCRE), $ matches the end of the subject OR right before a trailing newline, with or without the multiline flag. \A[A-Z0-9]{10}\z anchors the whole string instead and correctly rejects it.
Can one :httpc client be shared across processes?
Yes. :httpc is a client of the :inets application, not a per-process struct: any process in the VM can call :httpc.request/4 once :inets and :ssl are started, and it manages its own connection pool internally.
Bottom line: :httpc from Erlang's own :inets with a 120-second timeout and the key in an x-api-key header, Jason decoding that turns "N/A" into nil, a dedicated minor-units path for the statistics price, and retries for 429, 500, 502 and 503 only. The same client shape exists for Python, and every endpoint is in the API documentation.