Home / Blog / Amazon Product API in C#: HttpClient, System.Text.Json Records and Retries

Amazon Product API in C#: HttpClient, System.Text.Json Records and Retries

Call an Amazon product API from C# with HttpClient and System.Text.Json: one client with the key in the X-Api-Key header and a 120-second timeout, records for product lookup, offers and statistics, a converter that turns "N/A" into null, 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 C# MinorUnits record struct 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 C#, send a GET request with HttpClient to /api/amazon-product-lookup with an ASIN, a marketplace ID and your key in the X-Api-Key header, then deserialize the JSON into System.Text.Json records. 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. No NuGet package needed.

Key Takeaways

  • HttpClient gives up after 100 seconds by default. Set Timeout to 120 seconds: the API can take that long, and a request you abandon is still billed.
  • Send the key once through DefaultRequestHeaders as X-Api-Key, never as api_key in the URL: when both are sent, the query parameter wins.
  • Lookup sends buyBoxInfo.price as a number or the string "N/A". A small JsonConverter turns it into decimal?, so a missing price is null, never 0 and never a JsonException.
  • Statistics sends buyBoxPrice in integer minor units (41800 = 418.00); lookup and offers send decimals. A MinorUnits record struct keeps the two from mixing.
  • 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: .NET 8 and an API key

  • .NET 8 or later. The code uses only the base library (HttpClient, System.Text.Json), so dotnet new console is the whole setup. It was built and run on .NET 10 and on .NET 8.
  • 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, set the key, run it

dotnet --version                 # 8.0 or later; tested on .NET 10 and .NET 8
dotnet new console -n AmazonCsharp && cd AmazonCsharp
export SELLERMAGNET_API_KEY="your-key-here"
# save Program.cs, SellerMagnetClient.cs, Retry.cs and Models.cs from this post, then:
dotnet run                       # at most 3 credits per product, see the credit math below

How do I call the Amazon product API from C#?

Create one HttpClient and reuse it. Microsoft's guidance is to reuse instances for as many requests as possible, because every new connection costs time and a TCP port. Three settings matter here. Timeout goes to 120 seconds (the default is 100). BaseAddress must end with a slash, or a relative amazon-product-lookup replaces the api segment of the path. The key goes into DefaultRequestHeaders once. The header matters for more than tidiness: when a request carries both, the api_key query parameter wins, so a stale key left in a URL overrides a valid header, and a URL with a key ends up in logs and error messages. The header vs query string guide shows where it leaks.

SellerMagnetClient.cs - one client, one request, one decoded envelope

// One HttpClient: the key in the X-Api-Key header, a 120 s timeout, one decoded envelope per call.
using System.Text.Json;

namespace SellerMagnet;

/// <summary>The API answered with an error status. Message is "status: the API's own message".</summary>
public sealed class ApiException(int status, string message, TimeSpan? retryAfter, bool hasRetryAfter)
    : Exception($"{status}: {message}")
{
    public int Status { get; } = status;
    public TimeSpan? RetryAfter { get; } = retryAfter;
    public bool HasRetryAfter { get; } = hasRetryAfter;
}

public sealed partial class SellerMagnetClient : IDisposable
{
    public const string Api = "https://sellermagnet-api.com/api/";
    private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); // camelCase names
    private readonly HttpClient http;
    private int billed;

    public SellerMagnetClient(string baseUrl, string key, TimeSpan timeout)
    {
        // BaseAddress must end with "/": without it, "amazon-product-lookup" replaces the "api" segment.
        http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"), Timeout = timeout };
        http.DefaultRequestHeaders.Add("X-Api-Key", key); // a header, never api_key in the URL
    }

    /// <summary>Task.Delay in production; tests swap in a function that records the wait instead.</summary>
    public Func<TimeSpan, CancellationToken, Task> Delay { get; init; } = Task.Delay;

    /// <summary>Attempts the API charged a credit for, retries included (a timed-out request counts).</summary>
    public int Billed => Volatile.Read(ref billed);

    /// <summary>Key from SELLERMAGNET_API_KEY. HttpClient's default timeout is 100 s; the API can take 120 s.
    /// SELLERMAGNET_API_BASE is only for pointing the client at a local mock server in tests.</summary>
    public static SellerMagnetClient FromEnv()
    {
        var key = Environment.GetEnvironmentVariable("SELLERMAGNET_API_KEY");
        if (string.IsNullOrWhiteSpace(key))
        {
            throw new InvalidOperationException("set SELLERMAGNET_API_KEY");
        }
        var baseUrl = Environment.GetEnvironmentVariable("SELLERMAGNET_API_BASE") ?? Api;
        return new SellerMagnetClient(baseUrl, key, TimeSpan.FromSeconds(120));
    }

    /// <summary>One attempt. The retries live in Retry.cs (GetAsync).</summary>
    private async Task<T> GetOnceAsync<T>(string endpoint, IReadOnlyDictionary<string, string> query,
                                          CancellationToken ct)
    {
        var url = endpoint + "?" + string.Join("&", query.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
        HttpResponseMessage resp;
        try
        {
            resp = await http.GetAsync(url, ct);
        }
        catch (TaskCanceledException e) when (e.InnerException is TimeoutException)
        {
            Interlocked.Increment(ref billed); // charged up front, and the API may still be working on it
            throw;
        }
        using (resp)
        {
            var status = (int)resp.StatusCode;
            var hasRetryAfter = resp.Headers.RetryAfter is not null;
            // Charged: 200, 404, 502, 503 and a 500 that carries Retry-After. Free: 401, 403, 429 and the gate
            // 400s (an endpoint's own 400 is charged; this program never sends one). A plain 500 is refunded.
            if (status is 200 or 404 or 502 or 503 || (status == 500 && hasRetryAfter))
            {
                Interlocked.Increment(ref billed);
            }
            var text = await resp.Content.ReadAsStringAsync(ct);
            // The per-IP rate limit answers 429 with a JSON message: a body that still fails to decode is still an API error.
            using var doc = TryParse(text);
            var root = doc?.RootElement;
            if (status == 200 && root?.ValueKind == JsonValueKind.Object
                && root.Value.TryGetProperty("success", out var success) && success.ValueKind == JsonValueKind.True
                && root.Value.TryGetProperty("data", out var data))
            {
                return data.Deserialize<T>(Json) ?? throw new JsonException($"{endpoint}: data is null");
            }
            var message = root?.ValueKind == JsonValueKind.Object
                          && root.Value.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String
                ? m.GetString()!
                : "request failed";
            throw new ApiException(status, message, resp.Headers.RetryAfter?.Delta, hasRetryAfter);
        }
    }

    private static JsonDocument? TryParse(string text)
    {
        try
        {
            return JsonDocument.Parse(text);
        }
        catch (JsonException)
        {
            return null;
        }
    }

    public async Task<ProductInfo> LookupAsync(string asin, string marketplaceId, CancellationToken ct = default)
    {
        var data = await GetAsync<LookupData>("amazon-product-lookup", Params(asin, marketplaceId), ct);
        return data.ProductInfo ?? throw new JsonException("amazon-product-lookup: productInfo is missing");
    }

    public async Task<OffersData> OffersAsync(string asin, string marketplaceId, CancellationToken ct = default)
    {
        var o = await GetAsync<OffersData>("amazon-product-offers", Params(asin, marketplaceId), ct);
        // buyBox.totalPrice is price + shipping added as floats and not rounded (offers[].totalPrice is).
        // decimal keeps every digit of 406.09000000000003, so round it once, the way a shop rounds money.
        if (o.BuyBox is not { } box)
        {
            return o;
        }
        var total = Math.Round(box.TotalPrice, 2, MidpointRounding.AwayFromZero);
        return o with { BuyBox = box with { TotalPrice = total } };
    }

    public Task<Statistics> StatisticsAsync(string asin, string marketplaceId, CancellationToken ct = default) =>
        GetAsync<Statistics>("amazon-product-statistics", Params(asin, marketplaceId), ct);

    private static Dictionary<string, string> Params(string asin, string marketplaceId) =>
        new() { ["asin"] = asin, ["marketplaceId"] = marketplaceId };

    /// <summary>10 upper-case letters or digits. lookup and offers bill a malformed ASIN like any other.</summary>
    public static bool IsAsin(string s) =>
        s.Length == 10 && s.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c));

    public void Dispose() => http.Dispose();
}

GetOnceAsync returns the decoded data or throws an ApiException with the status, the API's own message and the Retry-After value, which resp.Headers.RetryAfter already parses into a TimeSpan. 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) answers 429 with a JSON message and no Retry-After, the same envelope shape as any other error status, and a body that still fails to decode becomes an ApiException too. A timeout surfaces as a TaskCanceledException whose inner exception is a TimeoutException. Billed counts the attempts the API charges, retries and timeouts included; it uses Interlocked, so parallel tasks can share the client.

How do I deserialize a price that is a number or "N/A"?

Attach a JsonConverter to the record's property with [property: JsonConverter(...)]. 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 decimal? property throws a JsonException on "N/A", even with JsonSerializerDefaults.Web, which accepts numbers in quotes but not text. The converter reads numbers and returns null for everything else. System.Text.Json ignores properties a record does not declare, so each record lists only what the program uses, and the Web defaults map buyBoxInfo to BuyBoxInfo without attributes.

Models.cs - records for lookup, offers and statistics

// Typed views of three responses. Only the properties used here are declared; System.Text.Json skips the rest.
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SellerMagnet;

/// <summary>lookup and offers send decimal currency units (444.99). lookup sends the string "N/A" when no
/// offer is featured, and a plain decimal property would fail the whole response. "N/A" and null become null.
/// </summary>
public sealed class DecimalOrNaConverter : JsonConverter<decimal?>
{
    public override decimal? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.Number)
        {
            return reader.GetDecimal(); // decimal keeps every digit the JSON text has, float noise included
        }
        reader.Skip(); // "N/A", null or anything else that is not a number
        return null;
    }

    public override void Write(Utf8JsonWriter writer, decimal? value, JsonSerializerOptions options)
    {
        if (value is { } v)
        {
            writer.WriteNumberValue(v);
        }
        else
        {
            writer.WriteNullValue();
        }
    }
}

/// <summary>statistics sends integer minor units (41800 = 418.00) or "N/A". Its own type, so a cents value
/// can never be added to or compared with a decimal lookup or offers price by accident.</summary>
public readonly record struct MinorUnits(long Value)
{
    /// <summary>Whole yen on amazon.co.jp; cents, pence or centavos elsewhere.</summary>
    public decimal ToDecimal(string marketplaceId) =>
        marketplaceId == "A1VC38T7YXB528" ? Value : Value / 100m;
}

public sealed record Statistics(
    [property: JsonConverter(typeof(MinorUnitsOrNaConverter))]
    MinorUnits? BuyBoxPrice, // landed price: item + shipping
    string? BuyBoxFulfillment); // null when nobody holds the Buy Box

public sealed class MinorUnitsOrNaConverter : JsonConverter<MinorUnits?>
{
    public override MinorUnits? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt64(out var value))
        {
            return new MinorUnits(value);
        }
        reader.Skip();
        return null;
    }

    public override void Write(Utf8JsonWriter writer, MinorUnits? value, JsonSerializerOptions options)
    {
        if (value is { } v)
        {
            writer.WriteNumberValue(v.Value);
        }
        else
        {
            writer.WriteNullValue();
        }
    }
}

public sealed record LookupData(ProductInfo? ProductInfo);

// Add title, bestsellerRanks, images ... as you need them.
public sealed record ProductInfo(string? Asin, BuyBoxInfo? BuyBoxInfo);

public sealed record BuyBoxInfo(
    [property: JsonConverter(typeof(DecimalOrNaConverter))] decimal? Price, // item price, no shipping; null = "N/A"
    string? CurrencyCode);

public sealed record Offer(
    string? SellerId, // "Amazon" when Amazon sells it; null when buyBox is {}
    string? SellerName,
    string? Condition,
    string? FulfillmentType, // "FBA" or "FBM"
    decimal PriceWithoutShipping, // 0.0 = the item price could not be read
    decimal TotalPrice, // item + shipping, decimal units
    int Inventory); // add-to-cart cap, not the seller's stock

public sealed record Currency(string? Code);

public sealed record OffersData(Offer? BuyBox, Currency? Currency, IReadOnlyList<Offer>? Offers)
{
    /// <summary>The featured offer, or null when the API sent buyBox: {}.</summary>
    public Offer? Featured() => string.IsNullOrEmpty(BuyBox?.SellerId) ? null : BuyBox;

    /// <summary>Cheapest new offer by total price, skipping offers whose item price was not read (0.0).</summary>
    public Offer? CheapestNew() =>
        (Offers ?? []).Where(o => o.Condition == "New" && o.PriceWithoutShipping > 0).MinBy(o => o.TotalPrice);
}

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 deserializes into an Offer with a null SellerId; Featured() turns that into 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 and can arrive as 406.09000000000003. A decimal keeps every digit of that text (a test in this post's harness checks it), so OffersAsync rounds it once to two places.

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 C# types that deserialize them safely (November 2026).
FieldEndpointJSON valueC# type
buyBoxInfo.pricelookup444.99 or "N/A"decimal? + converter
buyBoxoffers{} when noneOffer, SellerId null
buyBox.totalPriceoffersnot roundeddecimal, round once
offers[].totalPriceoffers418.00decimal
offers[].inventoryoffers30 (cart cap)int
buyBoxPricestatistics41800 or "N/A"MinorUnits? + converter
buyBoxFulfillmentstatistics"FBM" or nullstring?
Table mapping SellerMagnet API price fields to C# types: decimal? with a converter for the lookup price, MinorUnits for the statistics price in cents
Decimal on lookup and offers, cents on statistics: one C# type per format.

Retry policy: which errors a C# 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 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 C# 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
500Internal error, no Retry-AfterNo, refundedRetry once
502 / 503Retryable error / unavailableYesWait, retry, cap
TimeoutNo answer in 120 sYesReport, never resend

Retry.cs - retries that respect billing

// Retries only where a second attempt can help, and never for an answer the API already billed.
namespace SellerMagnet;

public sealed partial class SellerMagnetClient
{
    private const int Attempts = 4;

    /// <summary>429 is free, a 500 without Retry-After is refunded (retried once), 502 and 503 are billed
    /// on every attempt.</summary>
    private static readonly int[] Retryable = [429, 500, 502, 503];

    /// <summary>GET with retries for 429, 500 (once), 502 and 503 only. 400, 401, 403 and 404 are answers.
    /// </summary>
    public async Task<T> GetAsync<T>(string endpoint, IReadOnlyDictionary<string, string> query,
                                     CancellationToken ct = default)
    {
        var internalErrors = 0;
        for (var attempt = 1; ; attempt++)
        {
            TimeSpan wait;
            Exception error;
            try
            {
                return await GetOnceAsync<T>(endpoint, query, ct);
            }
            catch (ApiException e) when (Retryable.Contains(e.Status) && attempt < Attempts)
            {
                if (e.Status == 500 && ++internalErrors > 1)
                {
                    throw; // a second 500 for the same call: report it, don't loop
                }
                var backoff = Backoff(attempt);
                wait = e.RetryAfter is { } retryAfter && retryAfter > backoff ? retryAfter : backoff;
                error = e;
            }
            // A connect error never reached the API, so it cost nothing. A timeout is not retried: the first
            // request may still be running, and every attempt is billed.
            catch (HttpRequestException e) when (e.HttpRequestError == HttpRequestError.ConnectionError
                                                 && attempt < Attempts)
            {
                wait = Backoff(attempt);
                error = e;
            }
            // 404 = not on this marketplace: billed once, and a retry pays again for the same answer.
            Console.Error.WriteLine(
                $"{endpoint}: {error.Message} (retry {attempt}/{Attempts - 1} in {(int)wait.TotalSeconds} s)");
            await Delay(wait, ct);
        }
    }

    /// <summary>Exponential backoff with up to a second of jitter, so parallel workers don't retry in lockstep.
    /// </summary>
    private static TimeSpan Backoff(int attempt) =>
        TimeSpan.FromSeconds(Math.Min(1 << attempt, 30)) + TimeSpan.FromMilliseconds(Random.Shared.Next(1000));
}

HttpClient itself never resends a request because of a status code or a timeout: in the tests, a 503 that never clears reaches the server exactly 4 times and a timed-out lookup exactly once. A connection error (HttpRequestError.ConnectionError) never reached the API, so it is retried for free. Polly users should check the defaults before adding Microsoft's standard resilience handler: per Microsoft's documentation it cuts each attempt after 10 seconds and retries timeouts, 408, 429 and 5xx responses up to 3 times within 30 seconds. Against this API that turns one slow lookup into several billed ones. For 502 handling on the offers endpoint in particular, see the 502 retry guide.

Sequence of a C# 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

Program.cs 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). It 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. The first line pins the invariant culture: with a German regional setting, 444.99m.ToString("0.00") prints 444,99.

Program.cs - three endpoints per product, one row each

using System.Globalization;
using System.Text.Json;
using SellerMagnet;

// Prices print as 444.99 on every machine; a German Windows would print 444,99 with the default culture.
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

// (ASIN, marketplace ID). A product costs up to 3 credits: lookup, offers and statistics.
(string Asin, string Market)[] 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
];

SellerMagnetClient client;
try
{
    client = SellerMagnetClient.FromEnv();
}
catch (InvalidOperationException e)
{
    Console.Error.WriteLine(e.Message);
    return 1;
}

using (client)
{
    Console.WriteLine($"{"asin",-10}  {"marketplace",-14}  {"lookup",-10}  {"offers buy box",-30}  statistics");
    foreach (var (asin, market) in products)
    {
        try
        {
            Console.WriteLine($"{asin,-10}  {market,-14}  {await RowAsync(client, asin, market)}");
        }
        catch (ApiException e) when (e.Status is 401 or 403)
        {
            // A bad key or an empty balance: every further call would fail the same way.
            Console.Error.WriteLine($"stopping: {e.Message}");
            return 1;
        }
        catch (Exception e) when (e is ApiException or HttpRequestException or TaskCanceledException
                                      or JsonException)
        {
            Console.WriteLine($"{asin,-10}  {market,-14}  {e.Message}"); // reported per row, the run goes on
        }
    }
    Console.WriteLine($"credits charged: {client.Billed}");
}
return 0;

static async Task<string> RowAsync(SellerMagnetClient c, string asin, string market)
{
    if (!SellerMagnetClient.IsAsin(asin))
    {
        return "not an ASIN, skipped (0 credits)";
    }
    var product = await c.LookupAsync(asin, market); // a 404 ends the row: one credit, no further calls
    var info = product.BuyBoxInfo;
    var lookup = info?.Price is { } price ? $"{price:0.00} {info.CurrencyCode}" : "N/A";

    var offers = await c.OffersAsync(asin, market);
    var buyBox = (offers.Featured(), offers.CheapestNew()) switch
    {
        ({ } o, _) => $"{o.TotalPrice:0.00} {offers.Currency?.Code} {o.FulfillmentType} {o.SellerId}",
        (null, { } o) => $"none, cheapest new {o.TotalPrice:0.00}",
        _ => "none, no new offers",
    };

    // statistics buyBoxPrice is integer minor units (41800 = 418.00); lookup and offers are decimal.
    string stats;
    try
    {
        var s = await c.StatisticsAsync(asin, market);
        stats = s.BuyBoxPrice is { } m ? $"{m.Value} = {m.ToDecimal(market):0.00} {s.BuyBoxFulfillment}".TrimEnd()
                                       : "N/A";
    }
    catch (ApiException e) when (e.Status == 400)
    {
        stats = "not covered (free 400)";
    }
    catch (ApiException e) when (e.Status is not (401 or 403))
    {
        stats = $"error {e.Status}";
    }
    return $"{lookup,-10}  {buyBox,-30}  {stats}";
}

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 give the client a Delay that records the waits instead of sleeping; that is how this code was tested, with the same output under a German locale.

Frequently Asked Questions

Is there a C# SDK for the SellerMagnet API?

No, and none is needed. Every endpoint is a plain GET with query parameters and a JSON body, so HttpClient and System.Text.Json from the base library cover it.

Do I need SP-API credentials or an Amazon developer account?

No. Amazon's Selling Partner API needs a registered app that a selling partner authorizes, plus Login with Amazon token exchange. This API takes one key in the X-Api-Key header and returns public product data.

What timeout should a C# client use?

120 seconds. HttpClient defaults to 100, which cuts off answers the API is still working on, and a request the client abandons is still charged.

Why does System.Text.Json fail on buyBoxInfo.price?

Because the field is a number or the string "N/A" when no offer is featured. A decimal? property throws a JsonException on the text; a small JsonConverter that returns null for non-numbers fixes it.

Can I use Polly or AddStandardResilienceHandler?

Yes, with changed defaults. The standard handler cuts each attempt after 10 seconds and retries timeouts, 408, 429 and 5xx up to 3 times within 30 seconds. Raise both timeouts to 120 seconds and stop it from retrying timeouts, which the API bills.

Can several tasks share one client?

Yes. Microsoft recommends reusing an HttpClient for as many requests as possible, and the client in this post counts billed attempts with Interlocked. Run lookups with Task.WhenAll and keep the per-key concurrency limit in mind.

Bottom line: one HttpClient with a 120-second timeout and the key in the X-Api-Key header, records with a converter that turns "N/A" into null, a MinorUnits type for the statistics price, and retries for 429, 500, 502 and 503 only. 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