Home / Blog / Amazon Product API in Golang: net/http Client, Structs and Retries

Amazon Product API in Golang: net/http Client, Structs and Retries

Call an Amazon product API from Go with nothing but the standard library: a net/http client with a 120-second timeout, typed structs for product lookup and offers, a price type that survives "N/A", retries that never pay twice for the same miss, and a goroutine worker pool.

October 13, 2026
5 min read
SellerMagnet Team
Share & Bookmark
Code editor with the Go Price type from the quickstart whose UnmarshalJSON keeps a nil value when the API sends N/A

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_KEY environment 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
}
JSON fields and the Go types that decode them safely (October 2026).
FieldEndpointJSON valueGo type
buyBoxInfo.pricelookup444.99 or "N/A"Price (custom)
buyBoxInfo.currencyCodelookup"USD"string
buyBoxoffers{} when noneOffer, SellerID ""
buyBox.totalPriceoffersnot roundedfloat64 + round
offers[].totalPriceoffers419.83float64
offers[].inventoryoffers30 (cart cap)int
listingPrice.price.totalsearch"449.00"string
buyBoxPricestatistics41800 (cents)int
Table mapping SellerMagnet API JSON fields to the Go types that decode them, the N/A-safe price and the unrounded Buy Box total highlighted
Four endpoints, four price formats: pick the Go type per field.

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 codes, billing and the right Go client reaction (October 2026).
StatusMeaningChargedClient action
200SuccessYesDecode data
400Bad or missing parameterOnly an endpoint's own 400Fix the call
401 / 403Key or creditsNoStop
404Not on this marketplaceYesRecord, never retry
429Concurrency or per-IP limitNoWait Retry-After
500Internal error, no Retry-AfterNo - refundedRetry once
502 / 503Upstream / breakerYesWait, 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):
		}
	}
}
Terminal log of a Go goroutine retrying a free 429 after Retry-After and recording a billed 404 without retrying
Wait and retry a free 429; record a billed 404 and move on.

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.

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