To get Amazon product data in Perl, send one GET request with LWP::UserAgent to /api/amazon-product-lookup with an ASIN, a marketplace ID and your API key in the X-Api-Key header, then JSON::PP::decode_json the body. JSON::PP, threads and Thread::Queue ship with core Perl; LWP::UserAgent, HTTP::Request and URI come from the separate libwww-perl/URI distributions - one apt-get install libwww-perl or cpanm run, nothing more. This quickstart builds a small client for lookup, offers and statistics, adds a retry policy that never pays twice for an answer the API already billed, and runs bulk lookups on Thread::Queue workers.
Key Takeaways
JSON::PP,threadsandThread::Queueare core Perl; installlibwww-perl(forLWP::UserAgentandHTTP::Request) andURIonce via apt or cpanm - no further CPAN modules are needed.- Set the request timeout to 120 seconds; the API allows a scrape that long, and a request your client abandons early is still billed.
- Retry 429, 500, 502 and 503 only, and a 500 at most once (it is refunded, a second one is not). A 404 or an endpoint's own 400 ("Invalid product ASIN") is a final, billed answer - retrying it pays again for the same result.
- A free GATE 400 (missing parameter, bad marketplaceId, endpoint not covered on this marketplace) is checked before billing and never charged; any other 400 is.
- LWP::UserAgent is not documented thread-safe: give every thread its own client, as the bulk example does.
What do I need before I start?
- Perl 5.26 or later, plus the libwww-perl package (
LWP::UserAgent,HTTP::Request,URI) - oneapt-get install libwww-perlon Debian/Ubuntu, orcpanm LWP::UserAgent URI;JSON::PP,threadsandThread::Queueare already core and need nothing. - An API key - a free account includes 150 credits, one per request.
- The key in an environment variable, never hard-coded in the script.
Check Perl and put the key in the environment
perl -v # 5.26 or later
# Debian/Ubuntu: sudo apt-get install -y libwww-perl (LWP::UserAgent, HTTP::Request, URI)
export SELLERMAGNET_API_KEY="your-key-here"
perl main.pl # up to 15 credits for the 5 sample products below
How do I get Amazon product data in Perl?
Wrap one LWP::UserAgent in a small SellerMagnet package. get_once dies with a single SellerMagnet::ApiError object carrying the status, the API's own message and any Retry-After, so callers only ever see decoded data or that error. marketplaceId takes one of 23 marketplace IDs, such as ATVPDKIKX0DER for amazon.com. is_asin is anchored with \A...\z, not ^...$: Perl's ^...$ still matches before a trailing newline, so a value read from a file with a stray \n would otherwise slip through and get billed on /amazon-product-lookup, which does not validate the ASIN format itself.
SellerMagnet.pm - an LWP::UserAgent client with one error type
package SellerMagnet;
use strict;
use warnings;
use LWP::UserAgent;
use HTTP::Request;
use URI;
use JSON::PP qw(decode_json);
# One LWP::UserAgent per client, keep-alive. Auth is the X-Api-Key header, never api_key in the
# query string (a query parameter would win over it if both were sent, and it would also land in
# access logs - see the header-vs-query-string guide). SELLERMAGNET_API_BASE is only for pointing
# the client at a local mock server in tests; production code should not set it.
sub new {
my ($class, %args) = @_;
my $key = $args{key} // $ENV{SELLERMAGNET_API_KEY};
die "set SELLERMAGNET_API_KEY\n" unless defined $key && length $key;
my $base = $args{base} // $ENV{SELLERMAGNET_API_BASE} // 'https://sellermagnet-api.com/api';
my $ua = LWP::UserAgent->new(timeout => 120, agent => 'sellermagnet-perl-quickstart/1.0');
return bless { key => $key, base => $base, ua => $ua, billed => 0 }, $class;
}
# Number of attempts the API actually charged a credit for, including billed retries. Lets a
# caller print a true credit total instead of guessing from the call count.
sub billed { return $_[0]->{billed} }
# 10 upper-case letters or digits, anchored at both ends. \A...\z (not ^...$) so a value read with
# a trailing "\n" from a CSV or file does not slip through: ^...$ in Perl still matches before a
# trailing newline. lookup and offers do not validate the ASIN format at all - a malformed one is
# sent upstream and billed like any other request, so filtering here saves a credit, not a round trip.
sub is_asin {
my ($s) = @_;
return defined($s) && $s =~ /\A[A-Z0-9]{10}\z/ ? 1 : 0;
}
# One GET, no retry (retries live in SellerMagnet::Retry::get_with_retry). Dies with a
# SellerMagnet::ApiError on anything other than "200 and success:true". A non-2xx body is not
# always JSON (the per-IP limiter answers 429 with a plain HTML page), so a failed decode becomes
# an empty hash instead of crashing the parser.
sub get_once {
my ($self, $endpoint, %params) = @_;
my $uri = URI->new("$self->{base}/$endpoint");
$uri->query_form(%params);
my $req = HTTP::Request->new(GET => $uri);
$req->header('X-Api-Key' => $self->{key});
my $resp = $self->{ua}->request($req);
if (!$resp->previous && $resp->code == 500 && $resp->message =~ /^(?:Can't connect|read timeout)/i) {
# LWP turns a connect/timeout failure into a synthetic "500" response with no body at all
# (status_line like "500 Can't connect to ..."); that is a transport failure, not a real
# server 500, and must never be read as billed or as a refund candidate.
die SellerMagnet::ApiError->new(status => -1, message => $resp->message, retry_after => 0,
transport => 1);
}
my $status = $resp->code;
my $retry_after = ($resp->header('Retry-After') // 0) + 0;
my $body = {};
my $raw = $resp->decoded_content;
if (defined $raw && length $raw) {
local $@;
my $decoded = eval { decode_json($raw) };
$body = $decoded if ref($decoded) eq 'HASH';
}
# Charged: 200, 404, 502, 503, and a 500 that carries Retry-After (still a real attempt).
# Free: 429, 401, 403, and a GATE 400 raised before billing (missing/blank required parameter,
# missing or unknown marketplaceId, or an endpoint not covered on this marketplace). Any OTHER
# 400 ("Invalid product ASIN", "Count Max: 50", "Invalid CategoryId", ...) is raised INSIDE the
# view AFTER the credit is deducted, so it IS charged - see the status-codes-and-billing
# guide. A 500 WITHOUT Retry-After is refunded (one retry costs nothing).
my $is_free_gate_400 = $status == 400 && defined($body->{message}) && (
$body->{message} =~ /Missing required parameter/
|| $body->{message} =~ /Invalid marketplaceId/
|| $body->{message} =~ /This endpoint is not available/
);
if ($status == 200 || $status == 404 || $status == 502 || $status == 503
|| ($status == 500 && $retry_after > 0)
|| ($status == 400 && !$is_free_gate_400)) {
$self->{billed}++;
}
return $body->{data} if $status == 200 && $body->{success};
die SellerMagnet::ApiError->new(
status => $status,
message => $body->{message} // $resp->message // 'request failed',
retry_after => $retry_after,
);
}
package SellerMagnet::ApiError;
sub new {
my ($class, %a) = @_;
return bless { status => $a{status}, message => $a{message}, retry_after => $a{retry_after} // 0,
transport => $a{transport} // 0 }, $class;
}
sub status { return $_[0]->{status} }
sub message { return $_[0]->{message} }
sub retry_after { return $_[0]->{retry_after} }
sub is_transport { return $_[0]->{transport} }
1;
Trimmed lookup response (example values)
{
"success": true,
"data": {
"productInfo": {
"asin": "B0CL61F39H",
"title": "PlayStation®5 console (slim)",
"buyBoxInfo": {"price": 444.99, "currencyCode": "USD", "sellerId": "Amazon"},
"link": "https://www.amazon.com/dp/B0CL61F39H"
}
}
}
buyBoxInfo.price is a decimal number, or the string "N/A" when no offer is featured - the client checks the JSON type with a regex before trusting it as a number, instead of letting a naive numeric context turn "N/A" into 0. Every field is listed on the product lookup page.
Which errors should a Perl client retry?
Retry only what a second attempt can fix. A 429 from the per-key concurrency cap is free and carries Retry-After: 2. A 500 is our own internal error and its credit is refunded, so one retry costs nothing - but only the first one; a second 500 for the same call is reported, not retried a third time. 502 and 503 are billed on every attempt, so wait at least Retry-After and cap the attempts at 4 total. A 400 needs a closer look: a GATE 400 (a missing parameter, an unknown marketplaceId, or an endpoint not covered on that marketplace) is checked BEFORE billing and is free, but an endpoint's OWN 400 - /amazon-product-statistics rejecting a malformed ASIN with "Invalid product ASIN", for example - runs AFTER the credit is deducted and is charged like any other answer.
| Status | Meaning | Charged | Client action |
|---|---|---|---|
| 200 | success | yes | use data |
| 400 (gate) | missing/blank parameter, bad or unknown marketplaceId | no | fix the call |
| 400 (endpoint) | e.g. "Invalid product ASIN" | yes | fix the call, do not retry |
| 401 / 403 | key or credits | no | stop |
| 404 | not on this marketplace | yes | store as missing |
| 429 | concurrency cap | no | wait Retry-After |
| 500 | internal error | refunded | retry once |
| 502 / 503 | upstream / breaker | yes | wait, then retry, capped at 4 |
SellerMagnet/Retry.pm - retries only where a second attempt can help
package SellerMagnet::Retry;
use strict;
use warnings;
use Exporter 'import';
our @EXPORT_OK = qw(get_with_retry);
# select() on no filehandles is the classic Perl-core sleep-with-a-float; overridable so tests
# never really wait (production sleeps for real).
our $SLEEP = sub { my ($s) = @_; select(undef, undef, undef, $s) if $s > 0; };
# Retries only what a second attempt can fix: 429 (free, the per-key concurrency cap), a 500
# (refunded, retried once REGARDLESS of whether it carried Retry-After; a second consecutive 500
# is final, not retried again), and 502 / 503 (billed, capped at 4 attempts total). A 404, an
# endpoint 400 (e.g. "Invalid product ASIN"), 401 and 403 are final answers: retrying them pays
# again for the exact same result, or fails the same way
# forever, so get_once's die propagates straight to the caller on the first attempt. A transport
# error (connect refused, timeout) is retried like a connect failure, never like a billed status,
# because the first request may still be running on the server.
sub get_with_retry {
my ($client, $endpoint, %params) = @_;
my $max_attempts = 4;
my $refunded_retry_used = 0;
for (my $attempt = 1; $attempt <= $max_attempts; $attempt++) {
my $data = eval { $client->get_once($endpoint, %params) };
return $data unless $@;
my $err = $@;
die $err unless ref($err) && $err->isa('SellerMagnet::ApiError');
if ($err->is_transport) {
die $err if $attempt == $max_attempts;
warn sprintf("%s: transport: %s (retry %d/%d)\n", $endpoint, $err->message, $attempt,
$max_attempts - 1);
$SLEEP->($attempt);
next;
}
my $status = $err->status;
if ($status == 500 && !$refunded_retry_used) {
$refunded_retry_used = 1;
die $err if $attempt == $max_attempts;
warn sprintf("%s: 500: %s (retry %d/%d in 0s, refunded)\n", $endpoint, $err->message,
$attempt, $max_attempts - 1);
$SLEEP->(0);
next;
}
my $retryable = ($status == 429 || $status == 502 || $status == 503); # 500 handled above
die $err if !$retryable || $attempt == $max_attempts;
my $wait = $err->retry_after > $attempt ? $err->retry_after : $attempt;
warn sprintf("%s: %d: %s (retry %d/%d in %ds)\n", $endpoint, $status, $err->message,
$attempt, $max_attempts - 1, $wait);
$SLEEP->($wait);
}
return undef; # unreachable: the loop always returns or dies
}
1;

How do I run the quickstart end to end?
main.pl loops over five sample products, printing one line per ASIN/marketplace pair: a lookup price, the live offers Buy Box, and the price-history statistics. It skips a 9-character ASIN before it costs a credit, and stops immediately on a 401/403 instead of looping through the rest of the list and paying for the same failure five more times.
main.pl - five sample products, billing-aware
#!/usr/bin/env perl
use strict;
use warnings;
use lib '.';
use SellerMagnet;
use SellerMagnet::Retry qw(get_with_retry);
# (asin, marketplaceId). A product costs up to 3 credits: lookup, offers, statistics.
my @PRODUCTS = (
['B0CL61F39H', 'ATVPDKIKX0DER'], # amazon.com
['B0CLTBHXWQ', 'APJ6JRA9NG5V4'], # amazon.it
['B0CL61F39H', 'A2VIGQ35RCS4UG'], # amazon.ae: statistics does not cover it (free gate 400)
['B0NOTFOUND', 'ATVPDKIKX0DER'], # not on this marketplace: billed 404, never retried
['B0CL61F39', 'ATVPDKIKX0DER'], # 9 characters: skipped before it costs a credit
);
sub fmt_price {
my ($price, $code) = @_;
return 'N/A' unless defined $price;
return sprintf('%.2f %s', $price, $code // '');
}
sub row {
my ($client, $asin, $market) = @_;
return 'not an ASIN, skipped (0 credits)' unless SellerMagnet::is_asin($asin);
my $lookup = eval { get_with_retry($client, 'amazon-product-lookup', asin => $asin, marketplaceId => $market) };
if (my $err = $@) {
die $err if $err->status == 401 || $err->status == 403; # stop: every further call fails the same way
return sprintf('%s: %s', $err->status, $err->message);
}
my $info = $lookup->{productInfo} // {};
my $bbi = $info->{buyBoxInfo} // {};
# buyBoxInfo.price is a decimal number OR the string "N/A" when no offer is featured; a
# regex test keeps "N/A" from silently becoming 0.
my $lookup_price = (defined $bbi->{price} && $bbi->{price} =~ /\A-?\d+(?:\.\d+)?\z/) ? $bbi->{price} : undef;
my $lookup_cell = fmt_price($lookup_price, $bbi->{currencyCode});
my $offers = eval { get_with_retry($client, 'amazon-product-offers', asin => $asin, marketplaceId => $market) };
my $offer_cell;
if (my $err = $@) {
die $err if $err->status == 401 || $err->status == 403;
$offer_cell = sprintf('%s: %s', $err->status, $err->message);
} else {
my $bb = $offers->{buyBox} // {}; # {} when nobody holds the Buy Box
my $cur = $offers->{currency} // {};
if (ref($bb) eq 'HASH' && %$bb && defined $bb->{totalPrice} && $bb->{totalPrice} =~ /\A\d/ && $bb->{totalPrice} > 0) {
$offer_cell = sprintf('%.2f %s %s %s', $bb->{totalPrice}, $cur->{code} // '',
$bb->{fulfillmentType} // '', $bb->{sellerId} // '');
} else {
$offer_cell = 'none, no new offers';
}
}
my $stats_cell;
my $stats = eval { get_with_retry($client, 'amazon-product-statistics', asin => $asin, marketplaceId => $market) };
if (my $err = $@) {
die $err if $err->status == 401 || $err->status == 403;
$stats_cell = $err->status == 400 ? 'not covered (free 400)' : sprintf('%s: %s', $err->status, $err->message);
} else {
# statistics reports buyBoxPrice in integer MINOR units (41800 = 418.00); lookup/offers
# are decimal. Whole yen on amazon.co.jp, cents/pence/centavos everywhere else.
my $minor = $stats->{buyBoxPrice};
if (defined $minor && $minor =~ /\A-?\d+\z/) {
my $decimal = $market eq 'A1VC38T7YXB528' ? $minor : $minor / 100;
$stats_cell = sprintf('%d = %.2f %s', $minor, $decimal, $stats->{buyBoxFulfillment} // '');
} else {
$stats_cell = 'N/A';
}
}
return sprintf('%-11s %s %s', $lookup_cell, $offer_cell, $stats_cell);
}
my $client = SellerMagnet->new;
printf("%-11s %-15s %s\n", 'asin', 'marketplace', 'lookup / offers / statistics');
for my $p (@PRODUCTS) {
my ($asin, $market) = @$p;
my $line = eval { row($client, $asin, $market) };
if (my $err = $@) {
printf("%-11s %-15s stopping: %s\n", $asin, $market, ref($err) ? $err->message : $err);
last;
}
printf("%-11s %-15s %s\n", $asin, $market, $line);
}
printf("credits charged: %d\n", $client->billed);
Output (local HTTP::Daemon mock server; illustrative data)
asin marketplace lookup / offers / statistics
B0CL61F39H ATVPDKIKX0DER 444.99 USD 444.99 USD FBA Amazon 44499 = 444.99 FBA
B0CLTBHXWQ APJ6JRA9NG5V4 479.00 EUR 479.00 EUR FBA Amazon 47900 = 479.00 FBA
B0CL61F39H A2VIGQ35RCS4UG 1899.00 AED 1899.00 AED FBM FBM_SELLER_AE1 not covered (free 400)
B0NOTFOUND ATVPDKIKX0DER 404: Invalid product
B0CL61F39 ATVPDKIKX0DER not an ASIN, skipped (0 credits)
credits charged: 9
44499 = 444.99 FBAon amazon.com: statistics reportsbuyBoxPricein integer MINOR units (cents), unlike lookup/offers, which are already decimal. Whole yen on amazon.co.jp, cents/pence/centavos everywhere else.not covered (free 400)on amazon.ae:/amazon-product-statisticsreturns a free gate 400 there, so the row still has a lookup price and a Buy Box, just no history - a different failure mode from the billed 404 two rows below.404: Invalid productforB0NOTFOUND: one billed credit, no retry, and the row stops at the lookup call - offers and statistics are never requested for an ASIN that already failed lookup.not an ASIN, skipped (0 credits)for the 9-character value: caught byis_asinbefore any network call, unlikeB0NOTFOUND, which is a real 10-character ASIN and has to be billed to learn it does not exist on that store.
How do I run many lookups in parallel in Perl?
Use threads and a Thread::Queue. Perl releases the interpreter lock while a thread waits on the network, so threads give real concurrency for API calls. Each thread builds its own SellerMagnet client, because LWP::UserAgent is not documented thread-safe. Five workers stay well under the per-key concurrency cap; there is no batch endpoint, and the bulk ASIN lookup guide covers pacing thousands of products.
bulk.pl - five workers, one client each
#!/usr/bin/env perl
# Five workers, one client each (LWP::UserAgent is not documented thread-safe, so each thread
# builds its own), pulling ASINs from a shared Thread::Queue. Perl releases the interpreter lock
# while a thread blocks on network I/O, so threads give real concurrency for API calls; there is no
# batch endpoint, see https://sellermagnet-api.com/blog/bulk-asin-lookup-api for pacing thousands.
use strict;
use warnings;
use threads;
use Thread::Queue;
use lib '.';
use SellerMagnet;
use SellerMagnet::Retry qw(get_with_retry);
my @products = (['B0CL61F39H', 'ATVPDKIKX0DER'], ['B0CLTBHXWQ', 'APJ6JRA9NG5V4']);
my $jobs = Thread::Queue->new(@products);
$jobs->end; # no more items will be enqueued: dequeue() returns undef once it is drained
my $rows = Thread::Queue->new;
my @workers = map {
threads->create(sub {
my $client = SellerMagnet->new; # one client per thread
while (defined(my $pair = $jobs->dequeue)) {
my ($asin, $market) = @$pair;
my $row = eval {
my $data = get_with_retry($client, 'amazon-product-lookup', asin => $asin, marketplaceId => $market);
{ asin => $asin, title => $data->{productInfo}{title}, error => undef };
};
$rows->enqueue($row // { asin => $asin, error => $@->status });
}
});
} (1 .. 5);
$_->join for @workers;
while (defined(my $row = $rows->dequeue_nb)) {
printf("%-11s %-40s %s\n", $row->{asin}, $row->{title} // '', $row->{error} // 'ok');
}

Frequently Asked Questions
Do I need to install a CPAN module for the SellerMagnet API?
Just one: libwww-perl (for LWP::UserAgent and HTTP::Request), via apt or cpanm. JSON::PP, threads and Thread::Queue are already core Perl; every endpoint is a plain GET with query parameters, no SDK needed.
Which Perl version do I need?
Perl 5.26 or later. The code uses only core-style syntax and modules that have been stable in libwww-perl and core Perl for years.
What timeout should the client use?
120 seconds. The server allows a scrape that long, and a request the client gives up on early is still charged.
Is a gate 400 the same as an endpoint's own 400?
No. A gate 400 (missing parameter, bad marketplaceId) is checked before billing and is free. An endpoint's own 400, like "Invalid product ASIN" on the statistics endpoint, runs after the credit is deducted and is billed.
Can threads share one LWP::UserAgent?
Don't. LWP::UserAgent is not documented thread-safe, so create one client per thread, as bulk.pl does.
Does a second 500 for the same call get retried again?
No. The refund only covers one retry; a second consecutive 500 is reported to the caller instead of being retried a third time.
Bottom line: one LWP::UserAgent client with a 120-second timeout, one error type, retries for 429, 500 (once), 502 and 503 only, and a thread per worker for bulk work. The same pattern exists for Ruby and Python, and every endpoint is in the API documentation.