Home / Blog / Amazon Product API in PHP: Guzzle Client, Typed DTOs and Retries

Amazon Product API in PHP: Guzzle Client, Typed DTOs and Retries

Call an Amazon product API from PHP with Guzzle: one client with the key in the X-Api-Key header and a 120-second timeout, readonly DTO classes 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 07, 2026
8 min read
SellerMagnet Team
Share & Bookmark
Code editor with the PHP MinorUnits readonly class 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 PHP, send a GET request with Guzzle to /api/amazon-product-lookup with an ASIN, a marketplace ID and your key in the X-Api-Key header, then map the decoded JSON onto readonly classes. This quickstart builds one 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

  • Guzzle has no timeout unless you set one: its default of 0 waits forever. Set 120 seconds: the API can take that long, and a request you abandon is still billed.
  • Guzzle reports a timeout as a ConnectException, the same class as a refused connection. Resend only cURL errors 6 and 7 (the request never left); a timed-out request may still be running.
  • Send the key in the X-Api-Key header. Guzzle's exception messages print the full URL with its query string, so a key sent as api_key lands in every logged error.
  • Lookup sends buyBoxInfo.price as a number or the string "N/A"; statistics sends buyBoxPrice in integer minor units (41800 = 418.00). Map them to ?float and to a MinorUnits class.
  • 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: PHP 8.2, Composer and an API key

  • PHP 8.2 or later with the curl extension. The DTOs are readonly classes, which PHP 8.2 introduced; this code was run on PHP 8.2 and 8.5.
  • Composer, which installs Guzzle 7 and generates the autoloader for the src/ folder.
  • 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 project, install Guzzle, set the key

php --version                     # 8.2 or later: readonly classes need it
mkdir amazon-php && cd amazon-php
# save composer.json, src/Models.php, src/Client.php, src/Retries.php and run.php from this post, then:
composer install                  # Guzzle 7 and the autoloader for src/
export SELLERMAGNET_API_KEY="your-key-here"
php run.php                       # at most 3 credits per product, see the credit math below

composer.json - Guzzle, the curl extension and the autoloader

{
    "require": {
        "php": ">=8.2",
        "ext-curl": "*",
        "guzzlehttp/guzzle": "^7.9"
    },
    "autoload": {
        "classmap": ["src/"]
    }
}

How do I call the Amazon product API from PHP?

Create one Guzzle client and reuse it for every call. Three options matter. The key goes into the default headers once, as X-Api-Key. When a request carries both, the api_key query parameter wins, so a stale key left in a URL overrides a valid header; the header vs query string guide shows where URL keys leak. The timeout is 120 seconds instead of Guzzle's endless default. And http_errors is off, so a 404 or 429 comes back as a response to inspect instead of an exception whose message repeats the URL.

src/Client.php - one Guzzle client, one request, one envelope

<?php

declare(strict_types=1);

namespace SellerMagnet;

use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\RequestOptions;

/** The API answered with an error status; the message is the API's own text. */
final class ApiError extends \RuntimeException
{
    public function __construct(public readonly int $status, string $message, public readonly int $retryAfter = 0)
    {
        parent::__construct("$status: $message");
    }
}

final class Client
{
    use Retries;

    public const API = 'https://sellermagnet-api.com/api';
    /** 400s the API answers before it takes a credit. Every other 400 is charged. */
    private const FREE_400 = ['Missing required parameter', 'Invalid marketplaceId',
        'This endpoint is not available'];

    private readonly Guzzle $http;
    private int $billed = 0;
    /** @var \Closure(float): void sleeps for N seconds; tests swap in a closure that records the wait */
    public \Closure $sleep;

    public function __construct(string $key, string $base = self::API, float $timeout = 120.0)
    {
        $this->http = new Guzzle([
            'base_uri' => rtrim($base, '/') . '/',
            RequestOptions::HEADERS => ['X-Api-Key' => $key], // a header, never api_key in the URL
            RequestOptions::TIMEOUT => $timeout,              // Guzzle's default 0 waits forever
            RequestOptions::CONNECT_TIMEOUT => 10,
            RequestOptions::HTTP_ERRORS => false,             // every status is read below, none throws
        ]);
        $this->sleep = static fn (float $seconds) => usleep((int) ($seconds * 1_000_000));
    }

    /** Key from SELLERMAGNET_API_KEY. SELLERMAGNET_API_BASE only points the client at a local mock in tests. */
    public static function fromEnv(): self
    {
        $key = getenv('SELLERMAGNET_API_KEY');
        if (!is_string($key) || $key === '') {
            throw new \RuntimeException('set SELLERMAGNET_API_KEY');
        }
        return new self($key, getenv('SELLERMAGNET_API_BASE') ?: self::API);
    }

    /** Attempts the API charged a credit for, retries included (a timed-out request counts: it may have run). */
    public function billed(): int
    {
        return $this->billed;
    }

    public function lookup(string $asin, string $marketplaceId): Product
    {
        return Product::fromLookup($this->get('amazon-product-lookup', compact('asin', 'marketplaceId')));
    }

    public function offers(string $asin, string $marketplaceId): Offers
    {
        return Offers::fromArray($this->get('amazon-product-offers', compact('asin', 'marketplaceId')));
    }

    public function statistics(string $asin, string $marketplaceId): Statistics
    {
        return Statistics::fromArray($this->get('amazon-product-statistics', compact('asin', 'marketplaceId')));
    }

    /** 10 upper-case letters or digits. lookup and offers bill a malformed ASIN like any other request. */
    public static function isAsin(string $asin): bool
    {
        return preg_match('/\A[A-Z0-9]{10}\z/', $asin) === 1; // \z: '$' would accept a trailing "\n" from fgets()
    }

    /** One attempt: the decoded data, or an ApiError. Retries live in Retries::get(). */
    private function getOnce(string $endpoint, array $params): array
    {
        try {
            $response = $this->http->get($endpoint, [RequestOptions::QUERY => $params]);
        } catch (ConnectException $e) {
            $this->billed += (int) (($e->getHandlerContext()['errno'] ?? 0) === CURLE_OPERATION_TIMEDOUT);
            throw $e;
        }
        $status = $response->getStatusCode();
        $retryAfter = (int) $response->getHeaderLine('Retry-After');
        // A body that is not JSON (an HTML error page, an empty reply) still becomes an ApiError, never a crash.
        $body = json_decode((string) $response->getBody(), true);
        $message = is_array($body) && is_string($body['message'] ?? null) ? $body['message'] : 'request failed';
        // Charged: 200, 404, 502, 503, a 500 with Retry-After and an endpoint's own 400. Free: gate 400s, 401,
        // 403 and 429. A 500 without Retry-After is refunded.
        $free400 = $status === 400 && array_filter(self::FREE_400, fn ($p) => str_starts_with($message, $p));
        if (in_array($status, [200, 404, 502, 503], true) || ($status === 500 && $retryAfter > 0)
            || ($status === 400 && !$free400)) {
            $this->billed++;
        }
        if ($status === 200 && is_array($body) && ($body['success'] ?? false) === true) {
            return is_array($body['data'] ?? null) ? $body['data'] : throw new \UnexpectedValueException('no data');
        }
        throw new ApiError($status, $message, $retryAfter);
    }
}

getOnce() returns the decoded data or throws an ApiError 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) also answers 429, with a JSON message but no Retry-After, so the retry falls back to its own backoff; a body that is not JSON at all still becomes an ApiError instead of a decode error. Note the trailing slash on base_uri: Guzzle resolves relative paths like a browser, so .../api plus amazon-product-lookup would drop the /api segment. billed() counts the attempts the API charges, retries included.

How do I map a price that is a number or "N/A" in PHP?

Check the type before you cast. 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 (float) cast turns "N/A" into 0.0, a price that looks real; decimal() returns null instead and accepts a JSON integer such as 499 as 499.0. Each class reads only the fields this program uses, so add title, ranks or images when you need them.

src/Models.php - readonly DTOs for lookup, offers and statistics

<?php

declare(strict_types=1);

namespace SellerMagnet;

// Typed views of three responses. Each class reads only the fields this program uses from the decoded array.

/** statistics: integer minor units (41800 = 418.00). Its own type, so cents never mix with a decimal price. */
final readonly class MinorUnits
{
    public function __construct(public int $value)
    {
    }

    /** Whole yen on amazon.co.jp; cents, pence or centavos everywhere else. */
    public function toDecimal(string $marketplaceId): float
    {
        return $this->value / ($marketplaceId === 'A1VC38T7YXB528' ? 1 : 100);
    }
}

final readonly class Product
{
    public function __construct(
        public string $asin,
        public ?float $buyBoxPrice, // featured offer's item price without shipping; null = "N/A"
        public string $currencyCode,
    ) {
    }

    public static function fromLookup(array $data): self
    {
        $info = $data['productInfo'] ?? throw new \UnexpectedValueException('lookup: no productInfo');
        $buyBox = $info['buyBoxInfo'] ?? [];
        return new self((string) ($info['asin'] ?? ''), self::decimal($buyBox['price'] ?? null),
            (string) ($buyBox['currencyCode'] ?? ''));
    }

    /** A number in decimal units (444.99), or the string "N/A" when no offer is featured. */
    private static function decimal(mixed $value): ?float
    {
        return is_int($value) || is_float($value) ? (float) $value : null; // "N/A", null, missing: null, never 0.0
    }
}

final readonly class Offer
{
    public function __construct(
        public string $sellerId,
        public string $condition,
        public string $fulfillment, // FBA or FBM
        public float $itemPrice,    // priceWithoutShipping; 0.0 means the price could not be read
        public float $totalPrice,   // item + shipping, decimal units, rounded to the cent
        public int $inventory,      // the add-to-cart maximum, not the seller's stock
    ) {
    }

    public static function fromArray(array $o): self
    {
        return new self((string) ($o['sellerId'] ?? ''), (string) ($o['condition'] ?? ''),
            (string) ($o['fulfillmentType'] ?? ''), (float) ($o['priceWithoutShipping'] ?? 0),
            round((float) ($o['totalPrice'] ?? 0), 2), (int) ($o['inventory'] ?? 0));
    }
}

final readonly class Offers
{
    /** @param list<Offer> $offers */
    public function __construct(public ?Offer $buyBox, public array $offers, public string $currencyCode)
    {
    }

    public static function fromArray(array $data): self
    {
        $buyBox = $data['buyBox'] ?? [];
        return new self(
            is_array($buyBox) && ($buyBox['sellerId'] ?? '') !== '' ? Offer::fromArray($buyBox) : null, // {} = none
            array_map(Offer::fromArray(...), $data['offers'] ?? []),
            (string) ($data['currency']['code'] ?? ''),
        );
    }

    /** Cheapest new offer whose item price was read. */
    public function cheapestNew(): ?Offer
    {
        $best = null;
        foreach ($this->offers as $offer) {
            if ($offer->condition === 'New' && $offer->itemPrice > 0
                && ($best === null || $offer->totalPrice < $best->totalPrice)) {
                $best = $offer;
            }
        }
        return $best;
    }
}

final readonly class Statistics
{
    public function __construct(public ?MinorUnits $buyBoxPrice, public ?string $buyBoxFulfillment)
    {
    }

    public static function fromArray(array $data): self
    {
        $price = $data['buyBoxPrice'] ?? null; // "N/A" when nobody holds the Buy Box right now
        return new self(is_int($price) ? new MinorUnits($price) : null,
            is_string($data['buyBoxFulfillment'] ?? null) ? $data['buyBoxFulfillment'] : null);
    }
}

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 json_decode turns into an empty array; Offers::fromArray() maps that to null. An offer whose item price could not be read arrives with priceWithoutShipping 0.0, so cheapestNew() skips it. inventory is the add-to-cart maximum, not the seller's stock. buyBox.totalPrice is not rounded by the API (it can read 444.97999999999996), and PHP's json_decode keeps that noise, so Offer rounds every total once.

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 toDecimal() 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 PHP types that map them safely (November 2026).
FieldEndpointJSON valuePHP type
buyBoxInfo.pricelookup444.99 or "N/A"?float
buyBoxoffers{} when none?Offer, null
buyBox.totalPriceoffersnot roundedfloat, round() once
offers[].totalPriceoffers418.00float
offers[].inventoryoffersadd-to-cart maxint
buyBoxPricestatistics41800 or "N/A"?MinorUnits
buyBoxFulfillmentstatistics"FBM" or null?string
Table mapping SellerMagnet API price fields to PHP types: a nullable float for the lookup price, MinorUnits for the statistics price in cents
Decimal on lookup and offers, cents on statistics: one PHP type per format.

Which errors should a PHP client retry?

Only those a second try can fix. 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 PHP 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 or back off
500Internal error, no Retry-AfterNo, refundedRetry once
502 / 503Retryable error / unavailableYesWait, retry, cap
TimeoutNo answer in 120 sYesReport, never resend

src/Retries.php - retries that respect billing

<?php

declare(strict_types=1);

namespace SellerMagnet;

use GuzzleHttp\Exception\ConnectException;

/** Retries only where a second attempt can help, and never for an answer the API already billed. */
trait Retries
{
    private const ATTEMPTS = 4;
    /** 429 is free, a 500 without Retry-After is refunded (retried once), 502 and 503 are billed per attempt. */
    private const RETRYABLE = [429, 500, 502, 503];
    /** Errors where the request never reached the API, so it cost nothing. */
    private const NOT_SENT = [CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT];

    /** GET with retries for 429, 500 (once), 502 and 503 only. 400, 401, 403 and 404 are answers. */
    public function get(string $endpoint, array $params): array
    {
        $internalErrors = 0;
        for ($attempt = 1; ; $attempt++) {
            try {
                return $this->getOnce($endpoint, $params);
            } catch (ApiError $e) {
                if (!in_array($e->status, self::RETRYABLE, true)) {
                    throw $e; // 404 = not on this marketplace: billed once, a retry pays for the same answer again
                }
                if ($e->status === 500 && ++$internalErrors > 1) {
                    throw $e; // a second 500 for the same call: report it, don't loop
                }
                $wait = max(self::backoff($attempt), $e->retryAfter);
            } catch (ConnectException $e) {
                // Guzzle reports a timeout (cURL error 28) as a ConnectException too. A timed-out request may
                // still be running and is billed, so only a request that never reached the API is sent again.
                if (!in_array($e->getHandlerContext()['errno'] ?? 0, self::NOT_SENT, true)) {
                    throw $e;
                }
                $wait = self::backoff($attempt);
            }
            if ($attempt === self::ATTEMPTS) {
                throw $e;
            }
            fprintf(STDERR, "%s: %s (retry %d/%d in %d s)\n", $endpoint, $e->getMessage(), $attempt,
                self::ATTEMPTS - 1, $wait);
            ($this->sleep)($wait);
        }
    }

    /** Exponential backoff with up to a second of jitter, so parallel workers do not retry in lockstep. */
    private static function backoff(int $attempt): float
    {
        return min(2 ** $attempt, 30) + random_int(0, 999) / 1000;
    }
}

Guzzle ships a retry middleware, but its default handler stack does not include it, so nothing is sent twice unless you ask for it. The trap is the exception class: Guzzle throws ConnectException for cURL error 28 (operation timed out) as well as for 6 (host not resolved) and 7 (could not connect). A loop that retries every ConnectException resends billed requests that timed out. The trait reads the cURL error number from the handler context and resends only 6 and 7. For 502 handling on the offers endpoint in particular, see the 502 retry guide.

Sequence of a PHP 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

run.php 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). The check uses \z, not $: in a PHP regex, $ also matches before a final newline, so a line read with fgets() would pass. The script 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.

run.php - three endpoints per product, one row each

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use SellerMagnet\ApiError;
use SellerMagnet\Client;

/** (ASIN, marketplace ID). A product costs up to 3 credits: lookup, offers and statistics. */
const PRODUCTS = [
    ['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
];

function row(Client $c, string $asin, string $market): string
{
    if (!Client::isAsin($asin)) {
        return 'not an ASIN, skipped (0 credits)';
    }
    $product = $c->lookup($asin, $market); // a 404 ends the row: one credit, no offers or statistics calls
    $price = $product->buyBoxPrice;
    $lookup = $price === null ? 'N/A' : sprintf('%.2f %s', $price, $product->currencyCode);

    $offers = $c->offers($asin, $market);
    $cheapest = $offers->cheapestNew();
    $buyBox = match (true) {
        $offers->buyBox !== null => sprintf('%.2f %s %s %s', $offers->buyBox->totalPrice, $offers->currencyCode,
            $offers->buyBox->fulfillment, $offers->buyBox->sellerId),
        $cheapest !== null => sprintf('none, cheapest new %.2f', $cheapest->totalPrice),
        default => 'none, no new offers',
    };

    // statistics buyBoxPrice is integer minor units (41800 = 418.00); lookup and offers are decimal.
    try {
        $s = $c->statistics($asin, $market);
        $stats = $s->buyBoxPrice === null ? 'N/A' : trim(sprintf('%d = %.2f %s', $s->buyBoxPrice->value,
            $s->buyBoxPrice->toDecimal($market), $s->buyBoxFulfillment ?? ''));
    } catch (ApiError $e) {
        $stats = match ($e->status) {
            401, 403 => throw $e,
            400 => 'not covered (free 400)',
            default => "error {$e->status}",
        };
    }
    return sprintf('%-10s  %-30s  %s', $lookup, $buyBox, $stats);
}

try {
    $client = Client::fromEnv();
} catch (RuntimeException $e) {
    fwrite(STDERR, $e->getMessage() . "\n");
    exit(1);
}
printf("%-10s  %-14s  %-10s  %-30s  statistics\n", 'asin', 'marketplace', 'lookup', 'offers buy box');
foreach (PRODUCTS as [$asin, $market]) {
    try {
        printf("%-10s  %-14s  %s\n", $asin, $market, row($client, $asin, $market));
    } catch (ApiError $e) {
        if (in_array($e->status, [401, 403], true)) {
            // A bad key or an empty balance: every further call would fail the same way.
            fwrite(STDERR, "stopping: {$e->getMessage()}\n");
            exit(1);
        }
        printf("%-10s  %-14s  %s\n", $asin, $market, $e->getMessage());
    } catch (RuntimeException $e) { // a timeout, a connection error or an unexpected response shape
        printf("%-10s  %-14s  %s\n", $asin, $market, $e->getMessage());
    }
}
printf("credits charged: %d\n", $client->billed());

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 set $client->sleep to a closure that records the waits; that is how this code was tested.

Frequently Asked Questions

Is there a PHP SDK for the SellerMagnet API?

No, and none is needed. Every endpoint is a plain GET with query parameters and a JSON body, so Guzzle for HTTP and a few readonly classes for the fields you use cover it.

Can I use plain cURL instead of Guzzle?

Yes. Set CURLOPT_TIMEOUT to 120, send the key with CURLOPT_HTTPHEADER as X-Api-Key and check curl_errno(): 28 means the request timed out. It is billed, so report it instead of sending it again.

What timeout should a PHP client use?

120 seconds. Guzzle's default of 0 waits forever, and a shorter timeout cuts off answers the API is still working on. A request the client abandons is still charged.

Why does PHP show 444.97999999999996 for a price?

Because offers sends buyBox.totalPrice unrounded and json_decode keeps the exact value. Round it once with round($price, 2) when you map it; offers[].totalPrice is already rounded to two decimals.

Why does my ASIN check let a value with a newline through?

Because $ in a PHP regex also matches before a final newline. Anchor the pattern with \A and \z, or trim() each line you read from a file.

Bottom line: one Guzzle client with a 120-second timeout and the key in the X-Api-Key header, readonly DTOs that turn "N/A" into null, a MinorUnits class for the statistics price, and retries for 429, 500, 502 and 503 that never resend a timeout. 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