Home / Blog / Amazon Product API in Rust: reqwest Client, serde Structs and Retries

Amazon Product API in Rust: reqwest Client, serde Structs and Retries

Call an Amazon product API from Rust with reqwest and serde: one blocking client with the key in a sensitive X-Api-Key header and a 120-second timeout, typed structs for product lookup, offers and statistics, a separate type for cents so a statistics price never mixes with a decimal one, and retries that never pay twice for the same answer.

November 06, 2026
7 min read
SellerMagnet Team
Share & Bookmark
Code editor with the Rust MinorUnits type from the quickstart that keeps the statistics price in cents and converts it to a decimal per marketplace

To call an Amazon product API from Rust, send a GET request with reqwest to /api/amazon-product-lookup with an ASIN, a marketplace ID and your key in the X-Api-Key header, then decode the JSON into serde structs. This quickstart builds one blocking client for three endpoints (lookup, offers and statistics), keeps the key out of every URL, gives the statistics price in cents its own type and retries only the errors a second attempt can fix.

Key Takeaways

  • reqwest's blocking client gives up after 30 seconds by default. Set the timeout to 120 seconds: the API can take that long, and a request you abandon is still billed.
  • Send the key in the X-Api-Key header and mark the header value sensitive, so Debug output shows "Sensitive" instead of the key. Never put it in the URL as api_key.
  • Lookup sends buyBoxInfo.price as a number or the string "N/A". Decode it into Option<f64>, so a missing price is None, never 0.0 and never a failed decode.
  • Statistics sends buyBoxPrice in integer minor units (41800 = 418.00); lookup and offers send decimals. A separate MinorUnits type keeps the two from mixing.
  • 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: Rust 1.85 and an API key

  • Rust 1.85 or later. reqwest 0.13 requires it, and cargo new then creates an edition 2024 crate.
  • An API key: a free account includes 150 credits after e-mail verification, one credit per request.
  • The key in the SELLERMAGNET_API_KEY environment variable, so it never lands in your repository.

Create the crate, add three dependencies, set the key

rustc --version                  # 1.85 or later: reqwest 0.13 needs it
cargo new amazon-rust && cd amazon-rust
cargo add reqwest --features blocking,json,query
cargo add serde --features derive
cargo add serde_json
export SELLERMAGNET_API_KEY="your-key-here"
# save src/main.rs, src/client.rs, src/retry.rs and src/models.rs from this post, then:
cargo run                        # at most 3 credits per product, see the credit math below

Cargo.toml - the dependencies the commands above add

[package]
name = "amazon-rust"
version = "0.1.0"
edition = "2024"

[dependencies]
reqwest = { version = "0.13", features = ["blocking", "json", "query"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

How do I call the Amazon product API from Rust?

Build one reqwest::blocking::Client and reuse it: it holds a connection pool, and the Client wrapper below is Send + Sync, so threads can share it. The key goes into the client's default headers once, marked sensitive. The header matters for more than tidiness: when a request carries both, the api_key query parameter wins, so a stale key left in a URL 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.

src/client.rs - one client, one request, one decoded envelope

//! One blocking client: the key in the X-Api-Key header, a 120 s timeout, one decoded envelope.
use std::fmt;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;

use reqwest::header::{HeaderMap, HeaderValue};
use serde::de::DeserializeOwned;

use crate::models::{LookupData, Offers, Product, Statistics};

pub const API: &str = "https://sellermagnet-api.com/api";

#[derive(Debug)]
pub enum Error {
    /// The API answered with an error status; message is the API's own text.
    Api {
        status: u16,
        message: String,
        retry_after: Option<Duration>,
    },
    /// No answer: DNS, connect, TLS or the timeout.
    Http(reqwest::Error),
    /// A 200 whose data did not match the struct.
    Decode(serde_json::Error),
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Error::Api {
                status, message, ..
            } => write!(f, "{status}: {message}"),
            Error::Http(e) => write!(f, "http: {e}"),
            Error::Decode(e) => write!(f, "decode: {e}"),
        }
    }
}

impl std::error::Error for Error {}

pub struct Client {
    http: reqwest::blocking::Client,
    base: String,
    billed: AtomicU32,
    /// std::thread::sleep in production; tests swap in a function that records the wait instead.
    pub sleep: fn(Duration),
}

impl Client {
    pub fn new(
        base: &str,
        key: &str,
        timeout: Duration,
    ) -> Result<Client, Box<dyn std::error::Error>> {
        let mut value = HeaderValue::from_str(key)?;
        value.set_sensitive(true); // Debug output prints "Sensitive", never the key
        let mut headers = HeaderMap::new();
        headers.insert("X-Api-Key", value); // a header, never a query parameter in the URL
        let http = reqwest::blocking::Client::builder()
            .default_headers(headers)
            .timeout(timeout)
            .build()?;
        Ok(Client {
            http,
            base: base.to_string(),
            billed: AtomicU32::new(0),
            sleep: std::thread::sleep,
        })
    }

    /// Key from SELLERMAGNET_API_KEY. reqwest's blocking default timeout is 30 s; the API can take up to 120 s.
    /// SELLERMAGNET_API_BASE is only for pointing the client at a local mock server in tests.
    pub fn from_env() -> Result<Client, Box<dyn std::error::Error>> {
        let key = std::env::var("SELLERMAGNET_API_KEY").map_err(|_| "set SELLERMAGNET_API_KEY")?;
        let base = std::env::var("SELLERMAGNET_API_BASE").unwrap_or_else(|_| API.to_string());
        Client::new(&base, &key, Duration::from_secs(120))
    }

    /// Attempts the API charged a credit for, retries included (a timed-out request counts: it may have run).
    pub fn billed(&self) -> u32 {
        self.billed.load(Ordering::Relaxed)
    }

    /// One attempt. Retries live in retry.rs (Client::get).
    pub(crate) fn get_once<T: DeserializeOwned>(
        &self,
        endpoint: &str,
        params: &[(&str, &str)],
    ) -> Result<T, Error> {
        let url = format!("{}/{endpoint}", self.base);
        let resp = self.http.get(url).query(params).send().map_err(|e| {
            if e.is_timeout() {
                self.billed.fetch_add(1, Ordering::Relaxed);
            }
            Error::Http(e)
        })?;
        let status = resp.status().as_u16();
        let retry_after = resp
            .headers()
            .get("Retry-After")
            .and_then(|v| v.to_str().ok()?.trim().parse().ok());
        let retry_after = retry_after.map(Duration::from_secs);
        // Charged: 200, 404, 502, 503 and a 500 that carries Retry-After. Free: 400 gate errors, 401, 403,
        // 429. A 500 without Retry-After is refunded.
        if matches!(status, 200 | 404 | 502 | 503) || (status == 500 && retry_after.is_some()) {
            self.billed.fetch_add(1, Ordering::Relaxed);
        }
        let text = resp.text().map_err(Error::Http)?;
        // The per-IP rate limit answers 429 with an HTML page: a body that is not JSON is still an API error.
        let body: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
        if status == 200 && body["success"] == true {
            return T::deserialize(&body["data"]).map_err(Error::Decode);
        }
        let message = body["message"]
            .as_str()
            .unwrap_or("request failed")
            .to_string();
        Err(Error::Api {
            status,
            message,
            retry_after,
        })
    }

    pub fn lookup(&self, asin: &str, marketplace_id: &str) -> Result<Product, Error> {
        let data: LookupData = self.get(
            "amazon-product-lookup",
            &[("asin", asin), ("marketplaceId", marketplace_id)],
        )?;
        Ok(data.product_info)
    }

    pub fn offers(&self, asin: &str, marketplace_id: &str) -> Result<Offers, Error> {
        let mut o: Offers = self.get(
            "amazon-product-offers",
            &[("asin", asin), ("marketplaceId", marketplace_id)],
        )?;
        // buyBox.totalPrice is price + shipping added as floats and not rounded (offers[].totalPrice is).
        o.buy_box.total_price = (o.buy_box.total_price * 100.0).round() / 100.0;
        Ok(o)
    }

    pub fn statistics(&self, asin: &str, marketplace_id: &str) -> Result<Statistics, Error> {
        self.get(
            "amazon-product-statistics",
            &[("asin", asin), ("marketplaceId", marketplace_id)],
        )
    }
}

/// 10 upper-case letters or digits. lookup and offers bill a malformed ASIN like any other request.
pub fn is_asin(s: &str) -> bool {
    s.len() == 10
        && s.bytes()
            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
}

get_once returns the decoded data or an Error::Api with the status, the API's own message and any Retry-After. 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 body that is not JSON still becomes an Error::Api instead of a decode error. billed() counts the attempts the API charges, retries included.

How do I decode a price that is a number or "N/A"?

Give the field a deserialize_with function that reads any JSON value and keeps it only if it is a number. 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. A plain f64 field would fail the whole response on "N/A"; Option<f64> turns it into None. serde ignores fields a struct does not declare, so each struct lists only what the program uses.

src/models.rs - typed lookup, offers and statistics

//! Typed views of three responses. Only the fields used here are declared; serde skips the rest.
use serde::{Deserialize, Deserializer};

/// lookup and offers: decimal currency units (444.99). lookup sends the string "N/A" when no offer
/// is featured, so a plain f64 field would fail the whole response. "N/A" and null become None.
fn decimal_or_na<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
    Ok(serde_json::Value::deserialize(d)?.as_f64())
}

/// statistics: integer minor units (41800 = 418.00) or "N/A". Its own type, so a cents value can
/// never be added to or compared with a decimal lookup/offers price by accident.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MinorUnits(pub i64);

impl MinorUnits {
    /// Whole yen on amazon.co.jp; cents, pence or centavos everywhere else.
    pub fn to_decimal(self, marketplace_id: &str) -> f64 {
        let divisor = if marketplace_id == "A1VC38T7YXB528" {
            1.0
        } else {
            100.0
        };
        self.0 as f64 / divisor
    }
}

fn minor_or_na<'de, D: Deserializer<'de>>(d: D) -> Result<Option<MinorUnits>, D::Error> {
    Ok(serde_json::Value::deserialize(d)?.as_i64().map(MinorUnits))
}

#[derive(Debug, Deserialize)]
pub struct LookupData {
    #[serde(rename = "productInfo")]
    pub product_info: Product,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Product {
    #[serde(default)]
    pub buy_box_info: BuyBoxInfo, // add title, bestsellerRanks, images ... as you need them
}

#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct BuyBoxInfo {
    #[serde(deserialize_with = "decimal_or_na")]
    pub price: Option<f64>, // featured offer's item price without shipping; None = "N/A"
    pub currency_code: String,
}

#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Offer {
    pub seller_id: String, // "Amazon" when Amazon sells it; "" when buyBox is {}
    pub seller_name: String,
    pub condition: String,
    pub fulfillment_type: String,    // "FBA" or "FBM"
    pub price_without_shipping: f64, // 0.0 = the item price could not be read
    pub total_price: f64,            // item + shipping, decimal units
    pub inventory: u32,              // add-to-cart cap, not the seller's stock
}

#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Currency {
    pub code: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Offers {
    #[serde(default)]
    pub buy_box: Offer, // {} when nobody holds the Buy Box
    #[serde(default)]
    pub currency: Currency,
    #[serde(default)]
    pub offers: Vec<Offer>, // first page only
}

impl Offers {
    /// The featured offer, or None when the API sent buyBox: {}.
    pub fn featured(&self) -> Option<&Offer> {
        (!self.buy_box.seller_id.is_empty()).then_some(&self.buy_box)
    }

    /// Cheapest new offer by total price, skipping offers whose item price was not read (0.0).
    pub fn cheapest_new(&self) -> Option<&Offer> {
        self.offers
            .iter()
            .filter(|o| o.condition == "New" && o.price_without_shipping > 0.0)
            .min_by(|a, b| a.total_price.total_cmp(&b.total_price))
    }
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Statistics {
    #[serde(default, deserialize_with = "minor_or_na")]
    pub buy_box_price: Option<MinorUnits>, // landed price (item + shipping); None = "N/A"
    #[serde(default)]
    pub buy_box_fulfillment: Option<String>, // null when nobody holds the Buy Box
}

Trimmed lookup response (example values from the API docs)

{
  "success": true,
  "data": {
    "productInfo": {
      "asin": "B0CL61F39H",
      "buyBoxInfo": {"price": 444.99, "currencyCode": "USD", "currencySymbol": "$"}
    }
  }
}

The offers endpoint returns the live Buy Box and the first page of offers. buyBox is an empty object when nobody wins it, which decodes into an Offer with an empty seller_id; featured() turns that into None. An offer whose item price could not be read arrives with priceWithoutShipping 0.0, so cheapest_new() skips it. 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. MinorUnits wraps the integer, and to_decimal divides by 100, except on amazon.co.jp, where the values are whole yen. More detail is in the cents vs decimal guide.

JSON fields and the Rust types that decode them safely (November 2026).
FieldEndpointJSON valueRust type
buyBoxInfo.pricelookup444.99 or "N/A"Option<f64>
buyBoxoffers{} when noneOffer, seller_id ""
buyBox.totalPriceoffersnot roundedf64, round once
offers[].totalPriceoffers418.00f64
offers[].inventoryoffers30 (cart cap)u32
buyBoxPricestatistics41800 or "N/A"Option<MinorUnits>
buyBoxFulfillmentstatistics"FBM" or nullOption<String>
Table mapping SellerMagnet API price fields to Rust types: Option of f64 for the lookup price, MinorUnits for the statistics price in cents
Decimal on lookup and offers, cents on statistics: one Rust type per format.

Retry policy: which errors a Rust 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 500 that carries Retry-After stays charged. 502 and 503 are billed on every attempt, so wait at least the Retry-After and cap the attempts. 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 codes, billing and the right Rust client reaction (November 2026).
StatusMeaningChargedClient action
200SuccessYesDecode data
400Bad parameter or uncovered marketplaceOnly an endpoint's own 400Fix the call
401 / 403Key or creditsNoStop the run
404Not on this marketplaceYesRecord, never retry
429Concurrency or per-IP limitNoWait Retry-After
500Internal error, no Retry-AfterNo, refundedRetry once
502 / 503Retryable error / unavailableYesWait, retry, cap
TimeoutNo answer in 120 sYesReport, never resend

src/retry.rs - retries that respect billing

//! Retries only where a second attempt can help, and never for an answer the API already billed.
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use serde::de::DeserializeOwned;

use crate::client::{Client, Error};

const ATTEMPTS: u32 = 4;
/// 429 is free, a 500 without Retry-After is refunded (retried once), 502 and 503 are billed per attempt.
const RETRYABLE: [u16; 4] = [429, 500, 502, 503];

impl Client {
    /// GET with retries for 429, 500 (once), 502 and 503 only. 400, 401, 403 and 404 are answers.
    pub fn get<T: DeserializeOwned>(
        &self,
        endpoint: &str,
        params: &[(&str, &str)],
    ) -> Result<T, Error> {
        let mut internal_errors = 0;
        let mut attempt = 0;
        loop {
            attempt += 1;
            let err = match self.get_once(endpoint, params) {
                Ok(data) => return Ok(data),
                Err(err) => err,
            };
            let wait = match &err {
                Error::Api {
                    status,
                    retry_after,
                    ..
                } if RETRYABLE.contains(status) => {
                    internal_errors += u32::from(*status == 500);
                    if internal_errors > 1 {
                        return Err(err); // a second 500 for the same call: report it, don't loop
                    }
                    backoff(attempt).max(retry_after.unwrap_or_default())
                }
                // A connect error never reached the API, so it cost nothing. A timeout is not retried:
                // the first request may still be running, and every attempt is billed.
                Error::Http(e) if e.is_connect() => backoff(attempt),
                _ => return Err(err), // 404 = not on this marketplace: billed once, retrying pays again
            };
            if attempt == ATTEMPTS {
                return Err(err);
            }
            eprintln!(
                "{endpoint}: {err} (retry {attempt}/{} in {} s)",
                ATTEMPTS - 1,
                wait.as_secs()
            );
            (self.sleep)(wait);
        }
    }
}

/// Exponential backoff with up to a second of jitter, so parallel workers do not retry in lockstep.
fn backoff(attempt: u32) -> Duration {
    let jitter = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |d| d.subsec_millis());
    Duration::from_secs((1u64 << attempt).min(30)) + Duration::from_millis(u64::from(jitter))
}

reqwest has a retry layer of its own, but by default it only resends a request that an HTTP/2 server refused before processing it. It never retries a status code or a timeout, so this loop decides what gets sent twice. For 502 handling on the offers endpoint in particular, see the 502 retry guide.

Sequence of a Rust client retrying a free 429 after Retry-After, then recording a billed 404 without retrying
Wait and retry a free 429; record a billed 404 and move on.

Run lookup, offers and statistics for five rows

main.rs checks each ASIN's format first, 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 whole run on 401 or 403 and prints one row per product. A 404 ends its row after one credit, without calling offers and statistics.

src/main.rs - three endpoints per product, one row each

mod client;
mod models;
mod retry;

use client::{Client, Error, is_asin};

/// (ASIN, marketplace ID). A product costs up to 3 credits: lookup, offers and statistics.
const PRODUCTS: [(&str, &str); 5] = [
    ("B0CL61F39H", "ATVPDKIKX0DER"),  // amazon.com
    ("B0CLTBHXWQ", "APJ6JRA9NG5V4"),  // amazon.it
    ("B0CL61F39H", "A2VIGQ35RCS4UG"), // amazon.ae: statistics does not cover it (free 400)
    ("B0NOTFOUND", "ATVPDKIKX0DER"),  // not on this marketplace: billed 404, never retried
    ("B0CL61F39", "ATVPDKIKX0DER"),   // 9 characters: skipped before it costs a credit
];

fn main() {
    let client = Client::from_env().unwrap_or_else(|e| {
        eprintln!("{e}");
        std::process::exit(1)
    });
    println!(
        "{:<10}  {:<14}  {:<10}  {:<30}  statistics",
        "asin", "marketplace", "lookup", "offers buy box"
    );
    for (asin, market) in PRODUCTS {
        match row(&client, asin, market) {
            Ok(line) => println!("{asin:<10}  {market:<14}  {line}"),
            // A bad key or an empty balance: every further call would fail the same way.
            Err(Error::Api {
                status: 401 | 403,
                message,
                ..
            }) => {
                eprintln!("stopping: {message}");
                std::process::exit(1)
            }
            Err(e) => println!("{asin:<10}  {market:<14}  {e}"),
        }
    }
    println!("credits charged: {}", client.billed());
}

fn row(c: &Client, asin: &str, market: &str) -> Result<String, Error> {
    if !is_asin(asin) {
        return Ok("not an ASIN, skipped (0 credits)".to_string());
    }
    let product = c.lookup(asin, market)?; // a 404 ends the row: one credit, no offers or statistics calls
    let info = &product.buy_box_info;
    let lookup = info.price.map_or("N/A".to_string(), |p| {
        format!("{p:.2} {}", info.currency_code)
    });

    let offers = c.offers(asin, market)?;
    let buy_box = match (offers.featured(), offers.cheapest_new()) {
        (Some(o), _) => format!(
            "{:.2} {} {} {}",
            o.total_price, offers.currency.code, o.fulfillment_type, o.seller_id
        ),
        (None, Some(o)) => format!("none, cheapest new {:.2}", o.total_price),
        (None, None) => "none, no new offers".to_string(),
    };

    // statistics buyBoxPrice is integer minor units (41800 = 418.00); lookup and offers are decimal.
    let stats = match c.statistics(asin, market) {
        Ok(s) => match (s.buy_box_price, s.buy_box_fulfillment) {
            (Some(m), Some(f)) => format!("{} = {:.2} {f}", m.0, m.to_decimal(market)),
            (Some(m), None) => format!("{} = {:.2}", m.0, m.to_decimal(market)),
            (None, _) => "N/A".to_string(),
        },
        Err(
            e @ Error::Api {
                status: 401 | 403, ..
            },
        ) => return Err(e),
        Err(Error::Api { status: 400, .. }) => "not covered (free 400)".to_string(),
        Err(Error::Api { status, .. }) => format!("error {status}"),
        Err(e) => format!("{e}"),
    };
    Ok(format!("{lookup:<10}  {buy_box:<30}  {stats}"))
}

Program output (illustrative data; the retry lines are stderr)

asin        marketplace     lookup      offers buy box                  statistics
amazon-product-lookup: 429: Max 40 concurrent requests per API key. Retry shortly. (retry 1/3 in 2 s)
B0CL61F39H  ATVPDKIKX0DER   444.99 USD  444.99 USD FBA A3853PJW50SJG8   44499 = 444.99 FBA
amazon-product-statistics: 500: An internal error occurred. Please try again shortly. (retry 1/3 in 2 s)
B0CLTBHXWQ  APJ6JRA9NG5V4   409.99 EUR  418.00 EUR FBM A2I59UVTUWUFH0   41800 = 418.00 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 and swap Client::sleep for a function that records the waits; that is how this code was tested.

Frequently Asked Questions

Is there a Rust SDK for the SellerMagnet API?

No, and none is needed. Every endpoint is a plain GET with query parameters and a JSON body, so reqwest for HTTP and serde for decoding cover it.

Should I use blocking or async reqwest?

Either works. The async reqwest::Client has the same builder, headers and timeout with .await added. Do not call the blocking client from inside an async runtime: reqwest documents that it panics there.

What timeout should a Rust client use?

120 seconds. reqwest's blocking client defaults to 30, which cuts off answers the API is still working on, and a request the client abandons is still charged.

Why does serde fail on buyBoxInfo.price?

Because the field is a number or the string "N/A" when no offer is featured. Decode it with a deserialize_with function into Option<f64> instead of a plain f64.

Can several threads share one client?

Yes. reqwest's Client holds a connection pool and is meant to be created once and reused, and the Client in this post is Send + Sync. Share it by reference in std::thread::scope.

Bottom line: one reqwest client with a 120-second timeout and the key in a sensitive header, serde structs that turn "N/A" into None, a MinorUnits type for the statistics price, and retries for 429, 500, 502 and 503 only. The same client exists for Python, and every endpoint is in the API documentation.

Ready to Extract Amazon Data at Scale?

Start building with SellerMagnet API today. Real-time product data, competitive pricing, and review analytics at your fingertips.

150 free API credits • No credit card required • Cancel anytime