To call an Amazon product API from Kotlin, send a GET request with the JDK's built-in java.net.http.HttpClient to /api/amazon-product-lookup with an ASIN, a marketplace ID and your key in the X-Api-Key header, then parse the JSON body into data 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
- java.net.http.HttpClient has no default timeout either: set
.timeout(Duration.ofSeconds(120))on every request. The API can take that long, and a request you abandon is still billed. - A timeout surfaces as HttpTimeoutException, a different exception than a refused connection (ConnectException). Neither is retried here: the request may still be running on the server, and it is already billed.
- Send the key in the X-Api-Key header, not the api_key query parameter. The query parameter wins when both are sent, and HttpClient logs and stack traces print the full request URI.
- Lookup sends buyBoxInfo.price as a JSON number or the string "N/A"; statistics sends buyBoxPrice in integer minor units (41800 = 418.00). Read the JSON type with gson before casting, and keep the statistics value in its own MinorUnits class.
- Retry 429, a 500 without Retry-After (once), a 500 WITH Retry-After, 502 and 503. A 404 or an endpoint 400 is a final, billed answer: retrying it pays again for the same result.
Prerequisites: JDK 21, Kotlin and an API key
- A JDK, 11 or later for
java.net.http; this code was built and run on OpenJDK 21 with the Kotlin 2.0 compiler (kotlinc). - gson on the classpath for JSON parsing; no other dependency is needed.
- 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.
Compile and run with kotlinc and the JDK directly
kotlinc -version # 2.0 or later; built and run on OpenJDK 21
mkdir amazon-kotlin && cd amazon-kotlin
# save Models.kt, Client.kt and Main.kt (all package sellermagnet) from this post under src/main/kotlin/sellermagnet/
# download gson once: https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar
kotlinc -cp gson-2.11.0.jar -d out src/main/kotlin/sellermagnet/*.kt
export SELLERMAGNET_API_KEY="your-key-here"
java -cp "out:gson-2.11.0.jar" sellermagnet.MainKt # at most 3 credits per product, see the credit math below
How do I call the Amazon product API from Kotlin?
Create one HttpClient and reuse it for every call. The key goes into a request header, as X-Api-Key, never into the query string: when a request carries both, the api_key parameter wins, so a stale key left in a URL overrides a valid header, and the header vs query string guide shows where URL keys leak into logs. Every request sets its own 120-second timeout, because the API can take that long and HttpClient's own default is to wait forever. getOnce() returns the decoded data object or throws an ApiError carrying the status, the API's own message and any Retry-After.
Client.kt - one HttpClient, one request, one envelope
package sellermagnet
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.net.http.HttpTimeoutException
import java.time.Duration
import kotlin.math.min
import kotlin.math.pow
/** The API answered with an error status; message is the API's own text, retryAfter in seconds (0 = absent). */
class ApiError(val status: Int, message: String, val retryAfter: Long = 0) : RuntimeException("$status: $message")
/** Gate 400s (checked BEFORE the credit is deducted) are free; every other 400 is charged. */
private val FREE_400_PHRASES = listOf("Missing required parameter", "Invalid marketplaceId",
"This endpoint is not available")
private fun isFreeGate400(message: String): Boolean = FREE_400_PHRASES.any { message.contains(it) }
/** One client, reused for every call. Auth is the X-Api-Key header (never api_key in the query
* string: the query parameter wins when both are sent, and a key in the URL lands in logs and
* stack traces - see the header-vs-query-string guide). */
class Client(
private val key: String,
private val base: String = System.getenv("SELLERMAGNET_API_BASE") ?: "https://sellermagnet-api.com/api",
val sleeper: (Double) -> Unit = { Thread.sleep((it * 1000).toLong()) },
private val requestTimeout: Duration = Duration.ofSeconds(120),
) {
// Guzzle/axios/fetch all default to no timeout; java.net.http.HttpClient does too unless you ask.
// The API can take up to ~120 s, and a request you abandon is still billed.
private val http: HttpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()
private var billedCalls = 0
fun billed(): Int = billedCalls
private fun getOnce(endpoint: String, params: Map<String, String>): JsonObject {
val query = params.entries.joinToString("&") { (k, v) -> "$k=${java.net.URLEncoder.encode(v, "UTF-8")}" }
val req = HttpRequest.newBuilder()
.uri(URI.create("$base/$endpoint?$query"))
.header("X-Api-Key", key)
.timeout(requestTimeout)
.GET()
.build()
val resp: HttpResponse<String> = http.send(req, HttpResponse.BodyHandlers.ofString())
val retryAfter = resp.headers().firstValue("Retry-After").orElse(null)?.toLongOrNull() ?: 0
val body = try {
JsonParser.parseString(resp.body()).asJsonObject
} catch (e: Exception) {
throw ApiError(resp.statusCode(), "non-JSON body: ${resp.body().take(80)}", retryAfter)
}
if (resp.statusCode() != 200 || body.get("success")?.asBoolean != true) {
throw ApiError(resp.statusCode(), body.get("message")?.asString ?: body.toString(), retryAfter)
}
return body.getAsJsonObject("data") ?: throw IllegalStateException("200 without data")
}
/** GET with retries for 429 (free), 500 without Retry-After (refunded, once), 502/503 and a
* 500 WITH Retry-After (all three billed, capped at 4 attempts). 401/403/404/a gate 400/an
* endpoint 400 are final answers: they are not retried, because retrying a 404 or an endpoint
* 400 pays again for the same answer, and 401/403 mean the run cannot continue at all. */
fun get(endpoint: String, params: Map<String, String>): JsonObject {
var refundedFiveHundreds = 0
var attempt = 1
while (true) {
try {
val data = getOnce(endpoint, params)
billedCalls++
return data
} catch (e: ApiError) {
when {
e.status == 401 || e.status == 403 -> throw e // free, stop the run
e.status == 429 -> {} // free, retry below
e.status == 400 && isFreeGate400(e.message ?: "") -> throw e // free gate, final
e.status == 400 -> { billedCalls++; throw e } // endpoint 400, final
e.status == 404 -> { billedCalls++; throw e } // billed, never retried
e.status == 500 && e.retryAfter == 0L -> {
refundedFiveHundreds++
if (refundedFiveHundreds > 1) throw e // stop looping; still free
}
e.status in setOf(500, 502, 503) -> billedCalls++ // billed, retry below
else -> { billedCalls++; throw e }
}
if (attempt >= 4) throw e
val backoff = min(2.0.pow(attempt), 20.0)
val wait = maxOf(backoff, e.retryAfter.toDouble())
System.err.println("$endpoint: ${e.message} (retry $attempt/3 in ${wait}s)")
sleeper(wait)
attempt++
} catch (e: HttpTimeoutException) {
billedCalls++ // the request reached the server; it is billed whether or not we saw the answer
throw ApiError(0, "client timeout after 120s (billed, not retried)")
}
}
}
fun lookup(asin: String, marketplaceId: String): Product =
Product.fromLookup(get("amazon-product-lookup", mapOf("asin" to asin, "marketplaceId" to marketplaceId)))
fun offers(asin: String, marketplaceId: String): Offers =
Offers.from(get("amazon-product-offers", mapOf("asin" to asin, "marketplaceId" to marketplaceId)))
fun statistics(asin: String, marketplaceId: String): Stats =
Stats.from(get("amazon-product-statistics", mapOf("asin" to asin, "marketplaceId" to marketplaceId)))
companion object {
/** An Amazon ASIN is exactly 10 upper-case alphanumerics. Checking this before a call saves a
* billed request: lookup/offers do NOT validate ASIN shape at all, and statistics answers a
* billed 400 "Invalid product ASIN" for anything malformed. */
fun isAsin(value: String): Boolean = Regex("\\A[A-Z0-9]{10}\\z").matches(value)
fun fromEnv(): Client {
val key = System.getenv("SELLERMAGNET_API_KEY")
?: throw IllegalStateException("SELLERMAGNET_API_KEY is not set")
return Client(key)
}
}
}
Every JSON body is {"success": ..., "data": ...} on success or carries a message on failure, so a non-2xx status or success: false both become an ApiError, never a silent null. billed() counts the attempts the API actually charged, retries included, which is how the credit math in this post's example output was checked.
How do I map a price that is a number or "N/A" in Kotlin?
Check the gson element's type before reading it as a number. 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. Calling .asDouble on a JsonPrimitive that holds "N/A" throws instead of silently returning 0.0, which is why decimal() checks isNumber first and returns null for anything else. Each data class reads only the fields this program uses; add title, ranks or images when you need them.
Models.kt - data classes for lookup, offers and statistics
package sellermagnet
import com.google.gson.JsonObject
/** statistics reports buyBoxPrice in integer minor units (41800 = 418.00). Its own type, so cents
* never mix with a decimal lookup/offers price. */
data class MinorUnits(val value: Long) {
/** Whole yen on amazon.co.jp; cents, pence or centavos elsewhere. */
fun toDecimal(marketplaceId: String): Double =
value / (if (marketplaceId == "A1VC38T7YXB528") 1.0 else 100.0)
}
/** Lookup: productInfo.buyBoxInfo.price is a number (decimal units) or the string "N/A" when no
* offer is featured. Never coerce "N/A" to 0.0 - read the JSON type first. */
data class Product(val asin: String, val buyBoxPrice: Double?, val currencyCode: String) {
companion object {
fun fromLookup(data: JsonObject): Product {
val info = data.getAsJsonObject("productInfo")
?: throw IllegalStateException("lookup: no productInfo")
val buyBox = info.getAsJsonObject("buyBoxInfo") ?: JsonObject()
return Product(
asin = info.get("asin")?.asString ?: "",
buyBoxPrice = decimal(buyBox.get("price")),
currencyCode = buyBox.get("currencyCode")?.asString ?: "",
)
}
/** A JSON number (int or float) in decimal units, or null for "N/A" / missing / null. */
private fun decimal(el: com.google.gson.JsonElement?): Double? {
if (el == null || el.isJsonNull || !el.isJsonPrimitive) return null
val prim = el.asJsonPrimitive
return if (prim.isNumber) prim.asDouble else null // "N/A" is a JsonPrimitive string, skipped
}
}
}
data class Offer(
val sellerId: String,
val fulfillment: String, // FBA or FBM
val itemPrice: Double, // priceWithoutShipping; 0.0 means the price could not be read
val totalPrice: Double, // item + shipping, decimal units; buyBox.totalPrice is NOT rounded
val inventory: Int,
) {
companion object {
fun from(o: JsonObject): Offer = Offer(
sellerId = o.get("sellerId")?.asString ?: "",
fulfillment = o.get("fulfillmentType")?.asString ?: "",
itemPrice = o.get("priceWithoutShipping")?.asDouble ?: 0.0,
totalPrice = o.get("totalPrice")?.asDouble ?: 0.0,
inventory = o.get("inventory")?.asInt ?: 0,
)
}
}
data class Offers(val asin: String, val currencyCode: String, val buyBox: Offer?, val offers: List<Offer>) {
/** The cheapest readable New offer; a 0.0 item price means it could not be read, so it is skipped. */
fun cheapestNew(): Offer? = offers.filter { it.itemPrice > 0.0 }.minByOrNull { it.totalPrice }
companion object {
fun from(data: JsonObject): Offers {
val buyBoxObj = data.getAsJsonObject("buyBox")
val buyBox = if (buyBoxObj != null && buyBoxObj.size() > 0) Offer.from(buyBoxObj) else null
val currency = data.getAsJsonObject("currency")
val list = data.getAsJsonArray("offers")?.map { Offer.from(it.asJsonObject) } ?: emptyList()
return Offers(
asin = data.get("asin")?.asString ?: "",
currencyCode = currency?.get("code")?.asString ?: "",
buyBox = buyBox,
offers = list,
)
}
}
}
data class Stats(val buyBoxPrice: MinorUnits?, val buyBoxFulfillment: String?) {
companion object {
fun from(data: JsonObject): Stats {
val priceEl = data.get("buyBoxPrice")
val price = if (priceEl != null && !priceEl.isJsonNull && priceEl.isJsonPrimitive &&
priceEl.asJsonPrimitive.isNumber
) MinorUnits(priceEl.asLong) else null // "N/A" (a JSON string) stays null, never 0
val fulEl = data.get("buyBoxFulfillment")
val fulfillment = if (fulEl != null && !fulEl.isJsonNull) fulEl.asString else null
return Stats(price, fulfillment)
}
}
}
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 JSON object when nobody wins it, which Offers.from() maps to null. An offer whose item price could not be read arrives with priceWithoutShipping 0.0, so cheapestNew() filters those out before picking the minimum. inventory is the add-to-cart maximum, not the seller's real stock. buyBox.totalPrice is not rounded by the API (it can read 444.97999999999996), and the JVM's own double arithmetic keeps that noise, so round it once when you display it.
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 raw Long, 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 | Kotlin type |
|---|---|---|---|
buyBoxInfo.price | lookup | 444.99 or "N/A" | Double? |
buyBox | offers | {} when none | Offer?, null |
buyBox.totalPrice | offers | not rounded | Double, round once |
offers[].totalPrice | offers | 418.00 | Double |
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 Kotlin 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, but a second 500 in a row stops the loop instead of retrying forever; a 500 that carries Retry-After stays charged and is retried like 502/503. 502 and 503 are billed on every attempt, so wait at least the Retry-After and cap the attempts at four. 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 |
Main.kt - three endpoints per product, one row each
package sellermagnet
/** (ASIN, marketplace ID). A product costs up to 3 credits: lookup, offers and statistics. */
val PRODUCTS = listOf(
"B0CL61F39H" to "ATVPDKIKX0DER", // amazon.com
"B0CLTBHXWQ" to "APJ6JRA9NG5V4", // amazon.it
"B0CL61F39H" to "A2VIGQ35RCS4UG", // amazon.ae: statistics does not cover it (free gate 400)
"B0NOTFOUND" to "ATVPDKIKX0DER", // not on this marketplace: billed 404, never retried
"B0CL61F39" to "ATVPDKIKX0DER", // 9 characters: skipped before it costs a credit
)
fun row(c: Client, asin: String, market: String): String {
if (!Client.isAsin(asin)) return "not an ASIN, skipped (0 credits)"
val product = c.lookup(asin, market) // a 404 ends the row: one credit, no offers/statistics calls
val lookupPrice = product.buyBoxPrice?.let { "%.2f %s".format(it, product.currencyCode) } ?: "N/A"
val offers = c.offers(asin, market)
val cheapest = offers.cheapestNew()
val buyBox = when {
offers.buyBox != null -> "%.2f %s %s %s".format(
offers.buyBox.totalPrice, offers.currencyCode, offers.buyBox.fulfillment, offers.buyBox.sellerId)
cheapest != null -> "none, cheapest new %.2f".format(cheapest.totalPrice)
else -> "none, no new offers"
}
// statistics buyBoxPrice is integer minor units (41800 = 418.00); lookup and offers are decimal.
val stats = try {
val s = c.statistics(asin, market)
s.buyBoxPrice?.let { "%d = %.2f %s".format(it.value, it.toDecimal(market), s.buyBoxFulfillment ?: "") }
?.trim() ?: "N/A"
} catch (e: ApiError) {
when (e.status) {
401, 403 -> throw e
400 -> "not covered (free 400)"
else -> "error ${e.status}"
}
}
return "%-11s %-15s %-11s %-10s %-24s %s".format(asin, market, lookupPrice, "", buyBox, stats)
}
fun main() {
val client = Client.fromEnv()
println("%-11s %-15s %-11s %-10s %-24s %s".format(
"asin", "marketplace", "lookup", "", "offers buy box", "statistics"))
var exitCode = 0
for ((asin, market) in PRODUCTS) {
try {
println(row(client, asin, market))
} catch (e: ApiError) {
if (e.status == 401 || e.status == 403) {
System.err.println(e.message)
exitCode = 1
break
}
println("$asin $market ${e.message}")
}
}
println("credits charged: ${client.billed()}")
if (exitCode != 0) kotlin.system.exitProcess(exitCode)
}
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.0s)
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.0s)
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
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; the client reads it from the environment, falling back to the real API when it is unset, and that is how this code was tested.

Frequently Asked Questions
Is there a Kotlin SDK for the SellerMagnet API?
No, and none is needed. Every endpoint is a plain GET with query parameters and a JSON body, so java.net.http for HTTP and a few data classes for the fields you use cover it.
Can I use OkHttp instead of java.net.http?
Yes. Set callTimeout to 120 seconds, add the key with an Interceptor as X-Api-Key, and check for an IOException that is a timeout before deciding whether to retry; a timed-out request is billed, so it is reported, not resent.
What timeout should a Kotlin client use?
120 seconds. HttpClient's per-request .timeout() has no default, and a shorter timeout cuts off answers the API is still working on. A request the client abandons is still charged.
Why does my Kotlin program print 406.09000000000003 for a price?
Because offers sends buyBox.totalPrice unrounded and a JVM Double keeps the exact binary value. Format it with "%.2f".format(price) when you display it; offers[].totalPrice is already rounded to two decimals by the API.
Why does my ASIN check let a value with a newline through?
Because in Java/Kotlin regex, $ without MULTILINE still matches immediately before a final line terminator. Anchor the pattern with \A and \z, or trim() each line you read from a file, before checking its shape.
Bottom line: one HttpClient with a 120-second timeout and the key in the X-Api-Key header, data classes that turn "N/A" and null into Kotlin null, a MinorUnits class for the statistics price, and retries for 429, 500 (with the refund/charged split), 502 and 503 that never resend a timeout. The same client exists for PHP, and every endpoint is in the API documentation.