To call an Amazon product API from Swift, send a GET request with the standard library's URLSession to /api/amazon-product-lookup with an ASIN, a marketplace ID and your key in the X-Api-Key header, then decode the JSON body with JSONSerialization into plain structs. 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
- URLSession's own per-request timeout defaults to 60 seconds: set
timeoutIntervalForRequestto 120 on the session configuration. The API can take that long, and a request you abandon is still billed. URLSession.shared.dataTaskis callback-based; this client wraps it in aDispatchSemaphoreso a small command-line tool can call it like a normal throwing function, without pulling in Swift's async runtime just for one GET.- Send the key in the X-Api-Key header, not the api_key query parameter. The query parameter wins when both are sent, and a key left in a URL lands in shell history and server logs.
- Lookup sends buyBoxInfo.price as a JSON number or the string "N/A"; statistics sends buyBoxPrice in integer minor units (41800 = 418.00). Check the decoded type with
JSONSerializationbefore casting, and keep the statistics value in its own MinorUnits type. - 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: Swift 5.9+ and an API key
- The Swift toolchain, 5.9 or later for the Foundation networking stack used here; this code was built and run on the official swift.org 5.10.1 toolchain for Linux (Foundation ships
FoundationNetworkingforURLSessionthere; on macOS/iOS it is already in Foundation). - No external package:
Foundationalone covers URLSession, URLComponents and JSONSerialization. - 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.
Build and run with swiftc directly
swift --version # 5.9 or later; built and run on the official 5.10.1 Linux toolchain
mkdir amazon-swift && cd amazon-swift
# save Models.swift, Client.swift and main.swift from this post (main.swift must keep that exact
# lowercase name: swiftc only allows top-level code in a file named main.swift)
swiftc Models.swift Client.swift main.swift -o amazon-swift
export SELLERMAGNET_API_KEY="your-key-here"
./amazon-swift # at most 3 credits per product, see the credit math below
How do I call the Amazon product API from Swift?
Create one Client 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's session is configured with a 120-second timeout, because the API can take that long and URLSession's own default is 60 seconds. getOnce() returns the decoded data object or throws an ApiError carrying the status, the API's own message and any Retry-After.
Client.swift - one URLSession, one request, one envelope
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import Foundation
/// The API answered with an error status; message is the API's own text, retryAfter in seconds.
enum ApiError: Error, CustomStringConvertible {
case api(status: Int, message: String, retryAfter: Int)
case shape(String)
case transport(String)
var description: String {
switch self {
case .api(let status, let message, _): return "\(status): \(message)"
case .shape(let m): return m
case .transport(let m): return m
}
}
}
/// Gate 400s (checked BEFORE billing) are free; every other 400 is charged.
private let FREE_400_PHRASES = ["Missing required parameter", "Invalid marketplaceId",
"This endpoint is not available"]
private func isFreeGate400(_ message: String) -> Bool {
FREE_400_PHRASES.contains { message.contains($0) }
}
/// 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 server logs -
/// see the header-vs-query-string guide). sleeper is injectable so tests never really wait.
final class Client {
private let key: String
private let base: String
private let sleeper: (Double) -> Void
private let session: URLSession
private var billedCalls = 0
init(key: String,
base: String = ProcessInfo.processInfo.environment["SELLERMAGNET_API_BASE"]
?? "https://sellermagnet-api.com/api",
sleeper: @escaping (Double) -> Void = { Thread.sleep(forTimeInterval: $0) }) {
self.key = key
self.base = base
self.sleeper = sleeper
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 120 // URLSession's own default is 60 s; the API can take longer
self.session = URLSession(configuration: config)
}
static func fromEnv() -> Client {
guard let key = ProcessInfo.processInfo.environment["SELLERMAGNET_API_KEY"], !key.isEmpty else {
fatalError("set SELLERMAGNET_API_KEY")
}
return Client(key: key)
}
func billed() -> Int { billedCalls }
/// `\A...\z` anchors the whole string, unlike `^...$`, which still matches before a trailing
/// newline: a value read with a trailing "\n" from a CSV/file would otherwise pass as an ASIN.
static func isAsin(_ s: String) -> Bool {
s.range(of: "\\A[A-Z0-9]{10}\\z", options: .regularExpression) != nil
}
/// One HTTP GET. Throws ApiError.api for a non-2xx status or success:false, ApiError.transport
/// for a connection error or timeout (these are never retried below: the request may still be
/// running on the server, and it is already billed).
private func getOnce(_ endpoint: String, _ params: [String: String]) throws -> [String: Any] {
var comps = URLComponents(string: "\(base)/\(endpoint)")!
comps.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) }
var req = URLRequest(url: comps.url!)
req.httpMethod = "GET"
req.setValue(key, forHTTPHeaderField: "X-Api-Key")
let sem = DispatchSemaphore(value: 0)
var resultData: Data?
var resultResp: HTTPURLResponse?
var resultErr: Error?
let task = session.dataTask(with: req) { data, resp, err in
resultData = data; resultResp = resp as? HTTPURLResponse; resultErr = err
sem.signal()
}
task.resume()
sem.wait()
if let e = resultErr { throw ApiError.transport("\(e)") }
guard let resp = resultResp, let data = resultData else { throw ApiError.transport("no response") }
let retryAfter = Int(resp.value(forHTTPHeaderField: "Retry-After") ?? "") ?? 0
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
if resp.statusCode != 200 || (json?["success"] as? Bool) != true {
// A non-2xx body is not always JSON (e.g. a plain HTML 429 page from a proxy in
// front of the API): fall back to a generic message instead of failing to parse.
let message = json?["message"] as? String ?? "HTTP \(resp.statusCode)"
throw ApiError.api(status: resp.statusCode, message: message, retryAfter: retryAfter)
}
guard let data = json?["data"] as? [String: Any] else { throw ApiError.shape("no data field") }
return data
}
/// Retry only what a second attempt can fix: 429 (free), a 500 without Retry-After (refunded,
/// once), a 500 WITH Retry-After / 502 / 503 (billed, capped at 4 attempts total). A 404 or an
/// endpoint 400 is a final, billed answer: retrying it pays again for the same result.
func getWithRetry(_ endpoint: String, _ params: [String: String], maxAttempts: Int = 4) throws -> [String: Any] {
var attempt = 0
var refundedRetryUsed = false
while true {
attempt += 1
do {
let data = try getOnce(endpoint, params)
billedCalls += 1
return data
} catch ApiError.api(let status, let message, let retryAfter) {
let free = status == 429 || status == 401 || status == 403 || (status == 400 && isFreeGate400(message))
let refunded = status == 500 && retryAfter == 0
if !free && !refunded { billedCalls += 1 }
switch status {
case 429:
if attempt < maxAttempts {
let wait = Double(max(retryAfter, 1))
FileHandle.standardError.write("\(endpoint): \(status): \(message) (retry \(attempt)/\(maxAttempts - 1) in \(wait)s)\n".data(using: .utf8)!)
sleeper(wait); continue
}
case 500 where retryAfter == 0:
if !refundedRetryUsed && attempt < maxAttempts {
refundedRetryUsed = true
FileHandle.standardError.write("\(endpoint): \(status): \(message) (retry \(attempt)/\(maxAttempts - 1) in 0.0s, refunded)\n".data(using: .utf8)!)
sleeper(0) // refunded, so one immediate retry costs nothing
continue
}
case 500, 502, 503:
if attempt < maxAttempts {
let wait = Double(max(retryAfter, 2))
FileHandle.standardError.write("\(endpoint): \(status): \(message) (retry \(attempt)/\(maxAttempts - 1) in \(wait)s)\n".data(using: .utf8)!)
sleeper(wait); continue
}
default:
break // 404, endpoint 400, 401, 403: final, never retried
}
throw ApiError.api(status: status, message: message, retryAfter: retryAfter)
}
}
}
func lookup(_ asin: String, _ marketplaceId: String) throws -> Product {
try Product.fromLookup(try getWithRetry("amazon-product-lookup", ["asin": asin, "marketplaceId": marketplaceId]))
}
func offers(_ asin: String, _ marketplaceId: String) throws -> Offers {
Offers.from(try getWithRetry("amazon-product-offers", ["asin": asin, "marketplaceId": marketplaceId]))
}
func statistics(_ asin: String, _ marketplaceId: String) throws -> Statistics {
Statistics.from(try getWithRetry("amazon-product-statistics", ["asin": asin, "marketplaceId": marketplaceId]))
}
}
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 nil. A non-2xx body is also not always JSON: a plain-text 429 page from a proxy in front of the API still has to be handled by status code, not by assuming the body parses. 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 Swift?
Check the decoded JSON value's Swift 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. JSONSerialization hands back Any, so casting straight to Double on a dictionary value that holds the string "N/A" fails the cast and returns nil instead of crashing or silently becoming 0.0, which is why decimalPrice() tries Double then Int and falls through to nil for anything else. Each struct decodes only the fields this program uses; add title, ranks or images when you need them.
Models.swift - structs for lookup, offers and statistics
import Foundation
/// statistics reports buyBoxPrice in integer minor units (41800 = 418.00). Its own type, so cents
/// never mix with a decimal lookup/offers price.
struct MinorUnits {
let value: Int
/// Whole yen on amazon.co.jp; cents, pence or centavos elsewhere.
func toDecimal(marketplaceId: String) -> Double {
Double(value) / (marketplaceId == "A1VC38T7YXB528" ? 1.0 : 100.0)
}
}
/// A JSON price field: a number (decimal units) or the string "N/A" / missing / null. Reading the
/// JSON type first means "N/A" never silently becomes 0.0.
func decimalPrice(_ any: Any?) -> Double? {
if let d = any as? Double { return d }
if let i = any as? Int { return Double(i) }
return nil // "N/A", null or missing
}
struct Product {
let asin: String
let buyBoxPrice: Double?
let currencyCode: String
static func fromLookup(_ data: [String: Any]) throws -> Product {
guard let info = data["productInfo"] as? [String: Any] else {
throw ApiError.shape("lookup: no productInfo")
}
let buyBox = info["buyBoxInfo"] as? [String: Any] ?? [:]
return Product(asin: info["asin"] as? String ?? "",
buyBoxPrice: decimalPrice(buyBox["price"]),
currencyCode: buyBox["currencyCode"] as? String ?? "")
}
}
struct Offer {
let sellerId: String
let fulfillment: String // "FBA" or "FBM"
let itemPrice: Double // priceWithoutShipping; 0.0 means the price could not be read
let totalPrice: Double // item + shipping, decimal units; not rounded by the API
static func from(_ data: [String: Any]) -> Offer {
Offer(sellerId: data["sellerId"] as? String ?? "N/A",
fulfillment: data["fulfillmentType"] as? String ?? "N/A",
itemPrice: decimalPrice(data["priceWithoutShipping"]) ?? 0.0,
totalPrice: decimalPrice(data["totalPrice"]) ?? 0.0)
}
}
struct Offers {
let currencyCode: String
let buyBox: Offer?
let offers: [Offer]
static func from(_ data: [String: Any]) -> Offers {
let currency = data["currency"] as? [String: Any] ?? [:]
let buyBoxDict = data["buyBox"] as? [String: Any] ?? [:]
let buyBox = buyBoxDict.isEmpty ? nil : Offer.from(buyBoxDict) // {} when nobody holds it
let rows = (data["offers"] as? [[String: Any]] ?? []).map(Offer.from)
return Offers(currencyCode: currency["code"] as? String ?? "", buyBox: buyBox, offers: rows)
}
/// Skip offers whose item price could not be read (itemPrice 0.0) before taking a minimum.
func cheapestNew() -> Offer? {
offers.filter { $0.itemPrice > 0 }.min { $0.totalPrice < $1.totalPrice }
}
}
struct Statistics {
let buyBoxPrice: MinorUnits?
let buyBoxFulfillment: String?
static func from(_ data: [String: Any]) -> Statistics {
let priceField = data["buyBoxPrice"]
let price: MinorUnits?
if let i = priceField as? Int { price = MinorUnits(value: i) } else { price = nil } // "N/A" -> nil
return Statistics(buyBoxPrice: price, buyBoxFulfillment: data["buyBoxFulfillment"] as? String)
}
}
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 nil. An offer whose item price could not be read arrives with priceWithoutShipping 0.0, so cheapestNew() filters those out before picking the minimum. buyBox.totalPrice is not rounded by the API (it can read 406.09000000000003), and Swift's own Double arithmetic keeps that noise, so format it with "%.2f" when you display it rather than printing the raw Double.
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 Int, 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 | Swift type |
|---|---|---|---|
buyBoxInfo.price | lookup | 444.99 or "N/A" | Double? |
buyBox | offers | {} when none | Offer?, nil |
buyBox.totalPrice | offers | not rounded | Double, round once |
offers[].totalPrice | offers | 418.00 | Double |
offers[].itemPrice | offers | 0.0 = unread | Double, skip in min() |
buyBoxPrice | statistics | 41800 or "N/A" | MinorUnits? |
buyBoxFulfillment | statistics | "FBA" or null | String? |

Which errors should a Swift 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 or connection error is not retried here: 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.swift - three endpoints per product, one row each
import Foundation
/// (ASIN, marketplace ID). A product costs up to 3 credits: lookup, offers and statistics.
let PRODUCTS: [(String, String)] = [
("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
]
func fmt(_ price: Double, _ code: String) -> String {
"\(String(format: "%.2f", price)) \(code)"
}
/// Left-pad like printf's "%-Ns" without relying on String(format:)'s %s, which is unreliable
/// across Linux Foundation vs Darwin (varargs bridging differs); plain string ops are portable.
func pad(_ s: String, _ width: Int) -> String {
s.count >= width ? s : s + String(repeating: " ", count: width - s.count)
}
func row(_ c: Client, _ asin: String, _ market: String) -> String {
if !Client.isAsin(asin) { return "not an ASIN, skipped (0 credits)" }
let product: Product
do {
product = try c.lookup(asin, market) // a 404 ends the row: one credit, no offers/statistics calls
} catch let e as ApiError {
return "\(pad(asin, 11)) \(pad(market, 15)) \(e)"
} catch { return "\(pad(asin, 11)) \(pad(market, 15)) transport error" }
let lookupPrice = product.buyBoxPrice.map { fmt($0, product.currencyCode) } ?? "N/A"
let offersResult = try? c.offers(asin, market)
let buyBoxText: String
if let offersResult {
if let bb = offersResult.buyBox {
buyBoxText = "\(fmt(bb.totalPrice, offersResult.currencyCode)) \(bb.fulfillment) \(bb.sellerId)"
} else if let cheapest = offersResult.cheapestNew() {
buyBoxText = "none, cheapest new \(String(format: "%.2f", cheapest.totalPrice))"
} else {
buyBoxText = "none, no new offers"
}
} else {
buyBoxText = "offers error"
}
// statistics buyBoxPrice is integer minor units (41800 = 418.00); lookup and offers are decimal.
var statsText = "N/A"
do {
let stats = try c.statistics(asin, market)
if let price = stats.buyBoxPrice {
statsText = "\(price.value) = \(String(format: "%.2f", price.toDecimal(marketplaceId: market))) " +
"\(stats.buyBoxFulfillment ?? "")".trimmingCharacters(in: .whitespaces)
}
} catch ApiError.api(let status, _, _) {
statsText = status == 400 ? "not covered (free 400)" : "error \(status)"
} catch { statsText = "error" }
return "\(pad(asin, 11)) \(pad(market, 15)) \(pad(lookupPrice, 11)) \(buyBoxText) \(statsText)"
}
let client = Client.fromEnv()
print("\(pad("asin", 11)) \(pad("marketplace", 15)) \(pad("lookup", 11)) \(pad("buy box", 24)) statistics")
for (asin, market) in PRODUCTS {
let line = row(client, asin, market)
if !line.isEmpty { print(line) }
}
print("credits charged: \(client.billed())")
Program output (illustrative data; the retry lines are stderr)
asin marketplace lookup 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 0.0s, refunded)
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 Swift SDK for the SellerMagnet API?
No, and none is needed. Every endpoint is a plain GET with query parameters and a JSON body, so URLSession for HTTP and a few structs for the fields you use cover it.
Does this Swift client work on Linux, not just macOS/iOS?
Yes. On Linux, URLSession lives in the separate FoundationNetworking module (#if canImport(FoundationNetworking) import FoundationNetworking #endif); on Apple platforms it is already part of Foundation. The rest of the code is identical, and this post's tests ran on the official Linux toolchain.
What timeout should a Swift client use?
120 seconds. URLSessionConfiguration.timeoutIntervalForRequest defaults to 60, and a shorter timeout cuts off answers the API is still working on. A request the client abandons is still charged.
Why does my Swift program print 406.09000000000003 for a price?
Because offers sends buyBox.totalPrice unrounded and a Swift Double keeps the exact binary value. Format it with String(format: "%.2f", 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 NSRegularExpression (which range(of:options:.regularExpression) uses), $ can still match immediately before a trailing newline. Anchor the pattern with \A and \z, or trim() each line you read from a file, before checking its shape.
Bottom line: one URLSession with a 120-second timeout and the key in the X-Api-Key header, structs that turn "N/A" and null into Swift nil, a MinorUnits type 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 Kotlin, and every endpoint is in the API documentation.