To call an Amazon product API in Golang, send a GET request with net/http 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 typed structs - no third-party module, request signing or associate tag. The Go libraries that come up first wrap Amazon's Product Advertising API, which now refuses calls - see the PA-API alternative guide. This quickstart builds a client, typed lookup and offers calls, billing-aware retries and a worker pool.
Key Takeaways
- The Go standard library is enough: net/http, encoding/json, context and sync. The code builds with Go 1.21 or later.
- Set the client timeout to 120 seconds; the API allows a request that long, and an abandoned request is still billed.
- buyBoxInfo.price is a number or the string "N/A", so decode it into a custom type instead of float64.
- Retry 429, 500, 502 and 503 only. A 404 means the ASIN is not on that marketplace; retrying it pays for the same answer.
- One http.Client is safe for all goroutines; raise MaxIdleConnsPerHost above its default of 2 for a worker pool.
Prerequisites: Go 1.21 and an API key
- Go 1.21 or later - the retry helper uses the built-in
max. - 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 module and put the key in the environment
go version # go1.21 or later
mkdir amazon-go && cd amazon-go
go mod init example.com/amazon-go
export SELLERMAGNET_API_KEY="your-key-here"
# save client.go, get.go, lookup.go, offers.go, retry.go, row.go and main.go here
go vet . && go run . > products.csv # 2 lookups, 2 credits
How do I call the Amazon product API in Golang?
Create one http.Client for the whole program. Go's documentation states that clients are safe for concurrent use by multiple goroutines, and Client.Timeout covers connecting, redirects and reading the body - set it to 120 seconds. The transport keeps only 2 idle connections per host by default, so a pool of workers should raise MaxIdleConnsPerHost.
client.go - one shared client and one error type
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
const apiBase = "https://sellermagnet-api.com/api/"
// APIError carries the HTTP status, the API's message and any Retry-After.
type APIError struct {
Status int
Message string
RetryAfter time.Duration
}
func (e *APIError) Error() string { return fmt.Sprintf("%d: %s", e.Status, e.Message) }
// One client for every goroutine: an http.Client is safe for concurrent use.
var httpClient = newHTTPClient(8)
func newHTTPClient(workers int) *http.Client {
t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxIdleConnsPerHost = workers // the default of 2 would reopen connections
return &http.Client{Transport: t, Timeout: 120 * time.Second}
}
type envelope struct {
Success bool `json:"success"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
get sends the request and returns either the decoded data or an *APIError with the status, the API's own message and any Retry-After. Every response body is {"success": ..., "data": ...} on success or carries a message on failure. The per-IP rate limit (600 requests a minute, 15,000 an hour) answers 429 with an HTML body and no Retry-After, which is why a failed decode still produces an APIError.
get.go - one request, one decoded envelope
package main
import (
"context"
"encoding/json"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
// get sends one GET and decodes "data" into out, or returns an *APIError.
func get(ctx context.Context, endpoint string, params url.Values, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+endpoint+"?"+params.Encode(), nil)
if err != nil {
return err
}
req.Header.Set("X-Api-Key", os.Getenv("SELLERMAGNET_API_KEY"))
resp, err := httpClient.Do(req)
if err != nil {
return err // network error or the 120 s timeout
}
defer resp.Body.Close()
var env envelope
decodeErr := json.NewDecoder(resp.Body).Decode(&env) // a proxy's HTML page is not JSON
if resp.StatusCode == http.StatusOK && decodeErr == nil && env.Success {
return json.Unmarshal(env.Data, out)
}
msg := env.Message
if msg == "" {
msg = http.StatusText(resp.StatusCode)
}
secs, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
return &APIError{Status: resp.StatusCode, Message: msg, RetryAfter: time.Duration(secs) * time.Second}
}
How do I decode a field that is a number or "N/A"?
Give it its own type with an UnmarshalJSON method. productInfo.buyBoxInfo.price is the featured offer's item price in decimal units, or the string "N/A" when no offer is featured. A plain float64 field would fail the whole decode on "N/A"; the Price type below keeps a nil pointer instead, so "no price" never becomes zero. Unknown fields are ignored by encoding/json, so the structs only declare what you use.
lookup.go - typed product lookup with an N/A-safe price
package main
import (
"context"
"encoding/json"
"net/url"
)
// Price is a decimal number, or nil when the API sends "N/A" (no featured offer).
type Price struct{ Value *float64 }
func (p *Price) UnmarshalJSON(b []byte) error {
var f float64
if json.Unmarshal(b, &f) == nil {
p.Value = &f
}
return nil // "N/A" or null: Value stays nil instead of failing the whole decode
}
type Product struct {
ASIN string `json:"asin"`
Title string `json:"title"`
Link string `json:"link"`
BuyBoxInfo struct {
Price Price `json:"price"`
CurrencyCode string `json:"currencyCode"`
SellerID string `json:"sellerId"`
} `json:"buyBoxInfo"`
}
func Lookup(ctx context.Context, asin, marketplaceID string) (*Product, error) {
var data struct {
ProductInfo Product `json:"productInfo"`
}
params := url.Values{"asin": {asin}, "marketplaceId": {marketplaceID}}
if err := get(ctx, "amazon-product-lookup", params, &data); err != nil {
return nil, err
}
return &data.ProductInfo, nil
}
Trimmed lookup response (example values from the API docs)
{
"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"
}
}
}
The offers endpoint returns the live Buy Box and the first page of offers. Prices are decimal units; offers[].totalPrice is rounded to two decimals, but buyBox.totalPrice is price plus shipping added as floats and is not, so round it once after decoding. inventory is the add-to-cart maximum, not the seller's stock.
offers.go - the live Buy Box and offer list
package main
import (
"context"
"math"
"net/url"
)
type Offer struct {
SellerID string `json:"sellerId"` // "Amazon" when Amazon sells it
SellerName string `json:"sellerName"`
Condition string `json:"condition"`
FulfillmentType string `json:"fulfillmentType"` // "FBA" or "FBM"
TotalPrice float64 `json:"totalPrice"` // item + shipping, decimal units
Inventory int `json:"inventory"` // add-to-cart cap, not stock
}
type Offers struct {
ASIN string `json:"asin"`
BuyBox Offer `json:"buyBox"` // {} when no offer is featured: SellerID == ""
BuyBoxSuppressed bool `json:"buyBoxSuppressed"`
Offers []Offer `json:"offers"`
Currency struct {
Code string `json:"code"`
} `json:"currency"`
}
func GetOffers(ctx context.Context, asin, marketplaceID string) (*Offers, error) {
var o Offers
params := url.Values{"asin": {asin}, "marketplaceId": {marketplaceID}}
if err := get(ctx, "amazon-product-offers", params, &o); err != nil {
return nil, err
}
// buyBox.totalPrice is price + shipping as floats and is not rounded
o.BuyBox.TotalPrice = math.Round(o.BuyBox.TotalPrice*100) / 100
return &o, nil
}
| Field | Endpoint | JSON value | Go type |
|---|---|---|---|
buyBoxInfo.price | lookup | 444.99 or "N/A" | Price (custom) |
buyBoxInfo.currencyCode | lookup | "USD" | string |
buyBox | offers | {} when none | Offer, SellerID "" |
buyBox.totalPrice | offers | not rounded | float64 + round |
offers[].totalPrice | offers | 419.83 | float64 |
offers[].inventory | offers | 30 (cart cap) | int |
listingPrice.price.total | search | "449.00" | string |
buyBoxPrice | statistics | 41800 (cents) | int |

Retry policy: which errors a Go 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 a Retry-After header is our internal error and its credit is refunded, so one retry usually costs nothing; a 500 that carries Retry-After stays charged. 502 and 503 are billed per attempt, so wait at least the Retry-After and cap the attempts. A missing parameter is a free 400, but an endpoint's own 400 is billed, and lookup and offers bill a malformed ASIN like any request - validate it before sending.
| Status | Meaning | Charged | Client action |
|---|---|---|---|
200 | Success | Yes | Decode data |
400 | Bad or missing parameter | Only an endpoint's own 400 | Fix the call |
401 / 403 | Key or credits | No | Stop |
404 | Not on this marketplace | Yes | Record, never retry |
429 | Concurrency or per-IP limit | No | Wait Retry-After |
500 | Internal error, no Retry-After | No - refunded | Retry once |
502 / 503 | Upstream / breaker | Yes | Wait, then retry |
retry.go - retries that respect billing
package main
import (
"context"
"errors"
"math/rand"
"time"
)
var retryable = map[int]bool{429: true, 500: true, 502: true, 503: true}
// withRetry retries 429 (free), 500 (once; refunded without Retry-After), 502 and 503
// (billed per attempt). 400, 401, 403 and 404 are answers and come back at once.
func withRetry(ctx context.Context, attempts int, call func(context.Context) error) error {
seen500 := false
for attempt := 1; ; attempt++ {
err := call(ctx)
if err == nil {
return nil
}
wait := time.Duration(1<<attempt)*time.Second + time.Duration(rand.Int63n(int64(time.Second)))
var apiErr *APIError
if errors.As(err, &apiErr) {
if !retryable[apiErr.Status] || (apiErr.Status == 500 && seen500) {
return err
}
seen500 = seen500 || apiErr.Status == 500
wait = max(wait, apiErr.RetryAfter)
}
if attempt == attempts {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
}

Run many lookups with a goroutine worker pool
Feed ASINs into a channel and let a few goroutines drain it. All workers share the one client; a mutex guards the CSV writer. Five workers stay well under the per-key concurrency cap, and there is no batch endpoint - one ASIN per request. lookupRow skips anything that is not a 10-character ASIN before it costs a credit. For pacing thousands of products, see the bulk ASIN lookup guide.
row.go - one lookup as a CSV row
package main
import (
"context"
"regexp"
"strconv"
)
var asinPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`) // lookup bills malformed ASINs too
// lookupRow turns one lookup into a CSV row: asin, marketplace, price, currency, error.
func lookupRow(asin, marketplaceID string) []string {
row := []string{asin, marketplaceID, "", "", ""}
if !asinPattern.MatchString(asin) {
row[4] = "not an ASIN - skipped, no credit spent"
return row
}
var p *Product
err := withRetry(context.Background(), 4, func(ctx context.Context) error {
var err error
p, err = Lookup(ctx, asin, marketplaceID)
return err
})
if err != nil {
row[4] = err.Error() // e.g. "404: ..." - recorded, never retried
} else if v := p.BuyBoxInfo.Price.Value; v != nil {
row[2], row[3] = strconv.FormatFloat(*v, 'f', 2, 64), p.BuyBoxInfo.CurrencyCode
}
return row
}
main.go - five workers, one client, results to CSV
package main
import (
"encoding/csv"
"log"
"os"
"sync"
)
func main() {
if os.Getenv("SELLERMAGNET_API_KEY") == "" {
log.Fatal("set SELLERMAGNET_API_KEY")
}
jobs := make(chan [2]string) // {asin, marketplaceId}
out := csv.NewWriter(os.Stdout)
out.Write([]string{"asin", "marketplace", "price", "currency", "error"})
var mu sync.Mutex
var wg sync.WaitGroup
for w := 0; w < 5; w++ { // five workers share one http.Client
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
row := lookupRow(job[0], job[1])
mu.Lock()
out.Write(row)
mu.Unlock()
}
}()
}
for _, job := range [][2]string{{"B0CL61F39H", "ATVPDKIKX0DER"}, {"B0CLTBHXWQ", "APJ6JRA9NG5V4"}} {
jobs <- job
}
close(jobs)
wg.Wait()
out.Flush()
}
Frequently Asked Questions
Is there a Go SDK for the SellerMagnet API?
No, and none is needed. Every endpoint is a plain GET with query parameters and a JSON body, so net/http and encoding/json from the standard library cover it.
Which Go version do I need?
Go 1.21 or later, because the retry helper uses the built-in max function added in Go 1.21. Everything else is standard library that older versions also have.
Can goroutines share one http.Client?
Yes. Go documents http.Client as safe for concurrent use. Share one client and raise the transport's MaxIdleConnsPerHost to your worker count; the default is 2.
What timeout should a Go client use?
120 seconds on http.Client.Timeout. The server allows a request that long, and a request the client abandons early is still charged.
Do the old Go PA-API libraries still work?
No. PA-API 5 answers every call with an AccessDenied error that points to Amazon's Creators API, which is for affiliates with qualifying sales.
Bottom line: one shared http.Client with a 120-second timeout, structs that decode only what you use, a Price type for "N/A", retries for 429, 500, 502 and 503 only, and a small goroutine pool. The same client exists for Python, more snippets are on the code examples page, and every endpoint is in the API documentation.