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
readonlyclasses, 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_KEYenvironment 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.
| Field | Endpoint | JSON value | PHP type |
|---|---|---|---|
buyBoxInfo.price | lookup | 444.99 or "N/A" | ?float |
buyBox | offers | {} when none | ?Offer, null |
buyBox.totalPrice | offers | not rounded | float, round() once |
offers[].totalPrice | offers | 418.00 | float |
offers[].inventory | offers | add-to-cart max | int |
buyBoxPrice | statistics | 41800 or "N/A" | ?MinorUnits |
buyBoxFulfillment | statistics | "FBM" or null | ?string |

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 | 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 or back off |
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 |
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.

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.