The failure mode of an Amazon product data JSON API is not "no data came back." It is data that looks correct and is not. A parser that runs without an exception can still write two incomparable quantities into the same column, and nothing in your monitoring will notice for weeks.
This article works through a real case, using payloads captured on 2026-09-14 from the US marketplace. Two child ASINs share one parent, and six of their fields disagree. One page of reviews returns ten rows, and none of them belong to the ASIN that was requested. Both facts have direct consequences for how you model the data.
Everything below is runnable Python and TypeScript, and every sample value is quoted verbatim.
The case that motivates a field contract
Take one iPhone 15 Pro Renewed, 512GB, in white and in black. Both are children of parentAsin = B0GP8D698X. Their strikethroughPrice objects share a structure, share a type, and are non-empty:
// B0CMZFCQ6D (White 512GB)
"strikethroughPrice": { "key": "List Price", "value": "$649.00" }
// B0CMZ5KBNS (Black 512GB)
"strikethroughPrice": { "key": "Typical price", "value": "$629.95" }
List Price is the manufacturer's suggested retail price. Typical price is the 90-day median price paid by customers on Amazon. Two different baselines.
A parser that reads strikethroughPrice.value and labels it "original price" has just merged a suggested retail price with a median transaction price. The insert succeeds. The dashboard renders. The discount comparison is wrong.
Field-level defects share one property: they are silent. No parse exception, no null, no failed job. That rules out error-based detection. The only viable test is whether your assumption about a field holds across every variant and marketplace.
Divide the fields into four objects first
Before writing a dictionary, group the payload by business object. The fields are not a flat bag — they belong to four objects with different cadences and different nullability rules.
| Object | Representative fields | Cadence | Nullability policy |
|---|---|---|---|
| Identity |
asin parentAsin title itemName brand
|
Static | Required, non-empty |
| Transaction |
price strikethroughPrice inStock shipper seller
|
Minutes | Required, nullable, every null needs semantics |
| Reputation |
star rating ratingDistribution reviews
|
Daily | Model aggregates and details on separate tables |
| Spec |
attributes productOverview variantDetails images
|
Weekly | Use if present, degrade if absent |
That last row is a hard rule. Never mark spec fields required. Information completeness on Amazon varies by a wide margin, so strict validation on spec fields makes your pipeline throw on any product with incomplete attributes — and incomplete attributes are the norm, not the exception.
Grouping by business object rather than by page module also pays off in the architecture: identity maps to a key table, transaction to a fact table, spec to a sparse extension table. Group by DOM region instead and the dictionary has no clean mapping to storage.
Model three nullability states, not two
Key presence and business nullability are independent dimensions. Collapsing them produces two opposite modeling errors. The TypeScript below separates them, and the key move is using | null rather than ? to say "the key is always there, the value may be blank."
// Field contract: three nullability states, modeled as three types
type NonEmpty<T> = T; // always present, never blank
type Nullable<T> = T | null; // always present, value may be blank
type Optional<T> = T | undefined; // the key itself may be absent
interface ProductContract {
// --- Identity: required, non-empty ---
asin: NonEmpty<string>;
parentAsin: NonEmpty<string>;
title: NonEmpty<string>;
// --- Split title: key always present, value may be empty string ---
// Legacy listings always return an empty itemHighlights; do not use ?
itemName: Nullable<string>;
itemHighlights: Nullable<string>;
// --- Transaction: key always present, value may be blank ---
price: Nullable<string>; // e.g. "$628.95", currency symbol included
inStock: Nullable<string>; // free text, not an enum
shipper: Nullable<string>; // may be ""; "" != "no shipper"
savingsPercentage: Nullable<string>; // e.g. "6%", percent sign included
// --- Structured price: nested, and key is NOT a stable enum ---
strikethroughPrice: Nullable<{
key: string; // observed: "List Price" and "Typical price"
value: string;
tip: string;
}>;
// --- Spec fields: always optional, absence means degrade ---
attributes?: Array<{ key: string; value: string }>;
productOverview?: Array<{ key: string; value: string }>;
size?: string; // note: this is RAM, not storage capacity
}
Three entries in that interface map to measured traps.
Trap 1: the split title has a boundary you cannot see
As of 2026-07-27 Amazon splits the product title into itemName (the body) and itemHighlights (material, use case, selling points). The product sampled here is a legacy listing that has not been migrated. On such listings itemName equals the full title and itemHighlights is an empty string.
Assume that concatenating the two reconstructs the full title and the second half drops out on legacy listings. Assume itemHighlights always has a value and every legacy listing fails. Correct handling: use itemName, and fall back to title when itemHighlights is empty.
Trap 2: size holds RAM, not storage
This product returns size: "8 GB". It reads like storage. It is RAM. In the same payload, the attribute Memory Storage Capacity is "512 GB" and RAM Memory Installed is "8 GB".
Write size into a capacity column and every storage filter is off by a factor of 64. The problem compounds because the field's meaning shifts by category — for apparel size is a garment size, for phones it has been repurposed as a memory spec. Identical field name, different semantics.
Trap 3: the word "size" appears twice with two meanings
Top-level size is "8 GB" (RAM). variantDetails[].size is " 512GB " (storage, with surrounding whitespace). Same word, same JSON, two concepts. A contract that records only names misses this.
Diff two variants of one parent
If you do only one thing from this article, do this. Take at least two variants of the same parent and flatten every field for comparison. Here is what two children of B0GP8D698X produce:
| Field | White 512GB | Black 512GB | Risk |
|---|---|---|---|
strikethroughPrice.key |
List Price | Typical price | Different semantics |
strikethroughPrice.value |
$649.00 | $629.95 | Different baselines |
inStock |
Only 13 left in stock - order soon. | In Stock | Free text |
shipper |
(empty string) | Amazon | Null semantics undefined |
attributes length |
48 | 49 | Key set drifts |
Display Resolution Maximum |
2556 × 1179 pixels | 2556x1179 pixels | Fullwidth sign vs lowercase x |
product_dims |
6 x 4 x 2 inches | 5.77 x 2.78 x 0.33 inches | Different precision |
price |
$628.95 | $628.95 | Matches |
parentAsin |
B0GP8D698X | B0GP8D698X | Usable as grouping key |
rating |
(5258) | (5258) | Shared at parent level |
Field-level diff across two variants of parentAsin B0GP8D698X: discount baseline, attributes length, and resolution spelling
Six of ten rows disagree. Testing a single ASIN will never surface any of them.
Two rows deserve detail.
attributes is a sparse map, not a fixed schema. 48 entries versus 49; the extra key is Model Series. You cannot premise anything on "every variant has attribute X," and you cannot expand the array into fixed columns. Index by key, apply defaults on absence.
Spec text does not follow one spelling. The resolution field uses a fullwidth multiplication sign × (U+00D7) on one variant and a lowercase x on the other. Same physical spec, unequal strings. Exact-match on spec text registers them as different products — which breaks deduplication and clustering.
This is not a vendor defect. The Amazon page itself is inconsistent, and every scraper reproduces it as written. Normalize before storage:
import re
def normalize_resolution(value: str) -> str:
"""'2556 x 1179 pixels' and '2556x1179 pixels' must normalize equal."""
v = value.replace("\u00d7", "x").replace("\u00d7", "x")
v = re.sub(r"\s+", "", v.lower())
return v.replace("pixels", "")
The review payload's asin is not the ASIN you requested
This is the most consequential finding, and it is the reason a contract must be verified field by field rather than read off documentation.
We requested reviews for B0CMZFCQ6D. The API returned 10 reviews. Here is the asin distribution:
B0CMYXFK3R x2
B0CMZL2TJ9 x3
B0CMZBXYWX x1
B0CMZ7L14T x1
B0CRJRNTNS x1
B0CMZ9KS3G x1
B0CMZCGQDK x1
-----------------------------
distinct ASINs = 7
reviews matching the requested ASIN = 0
Requested ASIN B0CMZFCQ6D: 10 reviews across 7 distinct variants, 0 matching the requested ASIN
Not one of the ten carries the ASIN we asked for.
This is not an API defect. It reflects how Amazon attributes reviews: reviews attach to specific variants, and the variants of a product family share a review pool that the page aggregates for display. Each review's asin identifies which variant it came from.
Two consequences follow.
First, never write reviews[].asin back onto the main product record. Reviews end up attached to the wrong product and variant-level reputation analysis drifts.
Second, filter on asin when you need variant-level reviews. You cannot assume the payload was scoped to your request. Requesting the 512GB variant returned reviews whose attributes show Size: 256GB, Size: 1TB, and other variants — a distribution that affects whether that sample represents the variant you care about.
There is a finer format divergence hiding in the same field name. The reviews endpoint returns star as "1.0 out of 5 stars" (one decimal), while the reviews array inside the product payload returns "5 out of 5 stars" (integer). One field name, two endpoints, two formats. Share a parser between them and one side mis-parses or truncates without an error.
Null rules: three shapes, three behaviors
| Shape | Observed example | Semantics | Downstream behavior |
|---|---|---|---|
| Key present, empty string |
shipper: "", itemHighlights: ""
|
Not captured this run / not applicable | Keep raw value, flag unknown |
| Key present, null |
reviews: null, importantInfo: null
|
That block is absent from the page | Skip; not a failure |
| Key absent | Spec fields on some products | Merchant left the attribute blank | Apply default; count toward fill rate |
Do not normalize empty strings into null. They carry different information: an empty shipper means the shipper was not captured this run, while a null reviews means this product page has no review module. Merge them and you lose both fill-rate measurement and the ability to tell collection problems from page changes.
Fill rate answers "is the field populated." It does not answer "how old is the value," and those are separate failures. We covered the second one in Verifying "Real-Time" Amazon Data APIs: 3 Clocks, 5 Cache Signatures, and a 48-Hour Protocol — a field can be populated on every call and still carry a two-day-old snapshot. Monitor both, or one hides the other.
inStock is free text, and unknown must not default to available
The two observed values are " Only 13 left in stock - order soon. " and " In Stock " — note the surrounding whitespace. The field carries Amazon's raw page copy.
import re
def normalize_stock(raw: str | None) -> tuple[bool | None, int | None]:
"""Normalize inStock free text into (available, units_left).
(None, None) means undeterminable; the caller must treat it as
unknown and must not default to available.
"""
if not raw or not raw.strip():
return None, None
text = raw.strip().lower()
if "left in stock" in text:
m = re.search(r"(\d+)\s+left in stock", text)
return True, int(m.group(1)) if m else None
if "in stock" in text:
return True, None
if "unavailable" in text or "out of stock" in text:
return False, 0
return None, None # unrecognized form; keep raw text for review
The last line is the design point. Faced with an unrecognized stock string, mark it unknown and keep the raw text — do not default to available. Reading out-of-stock as available breaks restock alerts; reading available as out-of-stock triggers pointless emergency repricing.
Schema diff for version drift
Fields change. Amazon splits title fields, adjusts attribute key sets, ships page redesigns. Vendors change response structures. A contract needs a versioning strategy to absorb this.
The script below flattens a payload into {path: type} and diffs it against a baseline.
import json
from collections import defaultdict
def flatten(obj, prefix: str = "") -> dict:
"""Flatten nested JSON into {path: type}; arrays are suffixed with []."""
out = {}
if isinstance(obj, dict):
for k, v in obj.items():
out.update(flatten(v, f"{prefix}.{k}" if prefix else k))
elif isinstance(obj, list):
out[prefix + "[]"] = "array"
for item in obj[:5]: # first 5 samples only; bounded
out.update(flatten(item, prefix + "[]"))
else:
out[prefix] = type(obj).__name__
return out
def schema_diff(baseline: dict, current: dict) -> dict:
added = sorted(set(current) - set(baseline))
removed = sorted(set(baseline) - set(current))
retyped = sorted(
p for p in set(baseline) & set(current)
if baseline[p] != current[p]
)
return {"added": added, "removed": removed, "retyped": retyped}
def load(path):
with open(path, encoding="utf-8") as f:
return flatten(json.load(f))
if __name__ == "__main__":
diff = schema_diff(load("baseline.json"), load("current.json"))
for label, items in diff.items():
print(f"[{label}] {len(items)}")
for item in items:
print(" ", item)
# exit code for CI: any field change marks the run for review
raise SystemExit(1 if any(diff.values()) else 0)
The exit code is deliberate: any difference returns 1 so the pipeline marks the run "needs review" rather than failing outright. Added fields are harmless in most cases and can be accepted without review. Removed fields and type changes can break parsing. A contract test is a warning system, not a gate — over-blocking is how teams learn to route around it.
Contract tests: assert equivalence, not constants
import re
import pytest
# ---------- Trap 1: strikethroughPrice.key semantics are unstable ----------
@pytest.mark.parametrize("key,expected", [
("List Price", "list_price"),
("Typical price", "typical_price"),
])
def test_strikethrough_key_is_classified(key, expected):
assert classify_strikethrough(key) == expected
def classify_strikethrough(key: str) -> str:
k = key.strip().lower()
if "list price" in k:
return "list_price"
if "typical" in k:
return "typical_price"
return "unknown" # unmapped forms must surface for review
# ---------- Trap 2: spec text does not follow one spelling ----------
def test_resolution_variants_are_equal():
assert normalize_resolution("2556 \u00d7 1179 pixels") == \
normalize_resolution("2556x1179 pixels")
# ---------- Trap 3: review asin != requested asin ----------
def test_reviews_are_not_implicitly_filtered(reviews, requested_asin):
own = [r for r in reviews if r["asin"] == requested_asin]
others = [r for r in reviews if r["asin"] != requested_asin]
assert len(own) + len(others) == len(reviews)
# the point: writing back without filtering corrupts the product record
if others:
assert all(r["asin"] != requested_asin for r in others)
# ---------- Fill-rate monitor: earliest signal of an upstream change ----------
def test_fill_rate_does_not_regress(products, baseline: dict):
for field, floor in baseline.items():
filled = sum(1 for p in products if p.get(field) not in (None, "", []))
rate = filled / len(products)
assert rate >= floor, f"{field} fill rate {rate:.2%} below baseline {floor:.2%}"
The shared principle: assert normalized equivalence and explicit unknowns, not constant values. Upstream spellings for specs, stock copy, and discount baselines change. Hard-coded constants produce constant false alarms, and a suite that cries wolf gets ignored. Asserting equivalence after normalization survives spelling changes and fails only when semantics shift.
Cost tiering by tolerated delay
Field choices end at a cost question: which fields do you collect at high frequency, and which on a slow cycle.
| Field class | Movement | Suggested cadence | Orders per product per day |
|---|---|---|---|
| Transaction (price, stock, Buy Box, coupons) | Minutes | Minute-level polling | Thousands |
| Reputation aggregates (rating, count) | Daily | Daily | 1 |
| Review content, specs | Weekly | Weekly | ~0.1 |
| Identity | Static | Collect once, reuse | ~0 |
Per product, minute-level price polling means thousands of calls a day while weekly spec collection means dozens. Set the tolerated delay per field first, then choose the cadence, and only then compare vendors. Reverse that order and the price comparison is meaningless.
Field acquisition cost can also be optimized by endpoint choice. Product detail endpoints return dozens of fields per call at low unit cost; review and category endpoints often bill per page or per object at a higher unit cost. Carry identity, transaction, and spec fields on the product endpoint; call reviews only when review detail is needed, and prefer a critical-star filter to raise signal density per record.
Landing this on Pangolinfo
Every sample in this article came from running this contract process against real payloads. The traps — the title split boundary, the size semantics drift, the unstable discount baseline, the review asin not matching the request — were measured, not read off a spec sheet.
Pangolinfo's Amazon Scraper API returns structured JSON covering all four field classes above, and supports ZIP-code-specific collection so prices and delivery estimates match the region you care about. We hold a 99% success rate and 3-second median latency at over 30 million calls a day, with field fill rate monitored as its own metric alongside success rate — a green success rate can hide a payload missing a whole block of fields.
For review detail, the Amazon Review API filters by star rating, sort order, and media type, so you can pull critical reviews only and raise signal density per record.
On architecture, this walkthrough of the layers you still own in an Amazon data pipeline covers where the field contract belongs. Exact endpoint field definitions and response samples live in the Pangolinfo developer docs.
Write the contract before the parser. Reversed, the rework cost is an order of magnitude higher.


Top comments (0)