Skip to main content
Thirdwatchthirdwatch
E-commerce & products

Build an Amazon Best Sellers API Feed for Your Products

Turn Amazon's top-100 Best Sellers charts into your own cached JSON endpoint: scheduling, dataset retrieval, snapshot storage, cache TTL and node sharding.

Jul 28, 2026 · 8 min read · 1,929 words
See the scraper →

Amazon has no public Best Sellers API, so the practical way to give your app a ranked-chart endpoint is to run the Amazon Best Sellers Scraper on a schedule and serve its dataset from your own cache. Each run returns up to 100 ranked rows per chart with rank, asin, price, rating and scraped_at. Your service reads the last successful run, stores a snapshot, and answers requests from cache — never touching Amazon on the request path.

Why build a Best Sellers feed instead of scraping on request

If you are shipping software that displays or reasons about Amazon rankings — a price tracker, a sourcing dashboard, a recommendation layer, an internal analytics service — you need rankings as a dependency, not as an adventure. Scraping on the request path couples your p99 latency to Amazon's response time, makes your error budget a function of someone else's anti-bot posture, and multiplies your data cost by your traffic instead of by your refresh rate.

The economics point the other way too. Amazon's Best Sellers charts are recomputed roughly hourly from real order volume. That is the actual update frequency of the underlying data, which means a request-time fetch of the same chart returns the same rows for up to an hour. Fetching once per hour and serving from cache is not a compromise; it is the correct sampling rate.

There is also no official alternative to fall back on. Amazon's Product Advertising API returns attributes for ASINs you already have, and the Selling Partner API is scoped to your own seller account. Neither exposes the public ranked chart. So the chart page is the source, and the only real design question is where you put the caching layer — which is what the rest of this post answers.

How does this compare to the alternatives?

The three realistic ways to get chart data into your service differ mainly in who absorbs the breakage. A hand-rolled parser is the cheapest to start and the most expensive to keep alive, because Amazon's chart markup is partially lazy-loaded and a blocked response frequently arrives as a perfectly valid HTTP 200 with an empty grid. A generic scraping API gives you raw HTML and hands the parsing, rank integrity and pagination problems straight back to you. A purpose-built actor returns typed rows and absorbs the retry logic.

Approach Cost model Reliability Setup time Maintenance
DIY Python parser Your servers plus proxy contract Breaks on markup drift; silent empty renders 2-5 days Ongoing, unscheduled
Generic scraping API Per HTML page fetched Fetch succeeds, parsing is still yours 1-2 days You own every selector
Thirdwatch Amazon Best Sellers Scraper Transparent pay-per-result Rank read from Amazon's own metadata; retries on soft failures Under an hour Handled upstream

How to build an Amazon Best Sellers API feed in 4 steps

How do I pull one chart into a dataset with a single call?

Start with a synchronous call so you can see the shape of the data before you build anything around it. The actor takes a categories array where each entry is a slug, a slug/nodeId pair, or a full chart URL, plus listType, country and maxResults.

curl -X POST "https://api.apify.com/v2/acts/thirdwatch~amazon-bestsellers-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "categories": ["electronics", "kitchen"],
    "listType": "bestSellers",
    "country": "us",
    "maxResults": 100,
    "proxyConfiguration": {
      "useApifyProxy": true,
      "apifyProxyGroups": ["RESIDENTIAL"],
      "apifyProxyCountry": "US"
    }
  }'

That returns up to 200 rows — 100 ranks for each of the two charts — as a JSON array. Every row carries category_slug, country and list_type, so a multi-chart run is safe to write into one table without losing track of which chart a row came from.

How do I schedule the run so the feed refreshes itself?

Create the schedule once, then never invoke the actor from your application code again. Your service reads the output of the most recent successful run, which decouples your uptime from the scraper's.

import os, httpx

ACTOR = "thirdwatch~amazon-bestsellers-scraper"
TOKEN = os.environ["APIFY_TOKEN"]

# Called by your scheduler (or Apify's) once an hour.
def trigger_refresh() -> str:
    r = httpx.post(
        f"https://api.apify.com/v2/acts/{ACTOR}/runs",
        params={"token": TOKEN},
        json={
            "categories": ["electronics", "kitchen", "pet-supplies"],
            "listType": "bestSellers",
            "country": "us",
            "maxResults": 100,
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]["id"]

# Read whatever the last good run produced — never block on a live run.
def latest_rows() -> list[dict]:
    r = httpx.get(
        f"https://api.apify.com/v2/acts/{ACTOR}/runs/last/dataset/items",
        params={"token": TOKEN, "status": "SUCCEEDED", "clean": "true"},
        timeout=60,
    )
    r.raise_for_status()
    return r.json()

The status=SUCCEEDED filter matters. Without it, a run that is still in progress or that failed halfway can hand you a partial dataset, and your endpoint will quietly start serving a half-populated chart. See the Apify runs API reference for the full parameter list.

How do I store snapshots keyed on scraped_at?

Do not overwrite. Every row already carries scraped_at as an ISO 8601 UTC timestamp of the fetch, which turns your table into a time series for free — and a time series is what makes rank deltas, chart entries and drop-offs computable later.

import sqlite3, json

COLS = ["scraped_at", "country", "list_type", "category_slug", "rank",
        "asin", "title", "price", "currency", "rating", "reviews_count"]

db = sqlite3.connect("bestsellers.db")
db.execute(f"""
CREATE TABLE IF NOT EXISTS chart_snapshot ({", ".join(COLS)},
    PRIMARY KEY (scraped_at, country, list_type, category_slug, rank)
)""")

def store(rows: list[dict]) -> None:
    db.executemany(
        f"INSERT OR REPLACE INTO chart_snapshot VALUES ({','.join('?' * len(COLS))})",
        [tuple(r.get(c) for c in COLS) for r in rows],
    )
    db.commit()

The composite primary key on (scraped_at, country, list_type, category_slug, rank) makes re-ingesting the same run idempotent, so a retried webhook cannot double-count a chart.

How do I serve it as a cached endpoint with the right TTL?

Pin the TTL to the chart's own recompute cadence. Sixty minutes is the honest number: shorter buys you nothing but cost, longer starts to lie about "current" rankings.

from fastapi import FastAPI, HTTPException
from datetime import datetime, timezone

app = FastAPI()
TTL_SECONDS = 3600  # Amazon recomputes Best Sellers roughly hourly

@app.get("/v1/bestsellers/{country}/{category_slug}")
def bestsellers(country: str, category_slug: str, limit: int = 50):
    row = db.execute(
        "SELECT MAX(scraped_at) FROM chart_snapshot "
        "WHERE country=? AND category_slug=? AND list_type='bestSellers'",
        (country, category_slug),
    ).fetchone()[0]
    if not row:
        raise HTTPException(404, "no snapshot for that chart yet")

    items = db.execute(
        "SELECT rank, asin, title, price, currency, rating FROM chart_snapshot "
        "WHERE scraped_at=? AND country=? AND category_slug=? ORDER BY rank LIMIT ?",
        (row, country, category_slug, limit),
    ).fetchall()
    age = (datetime.now(timezone.utc) - datetime.fromisoformat(row)).total_seconds()
    return {"scraped_at": row, "stale": age > TTL_SECONDS, "items": items}

Returning stale rather than a 503 is the friendlier contract: a consumer that can tolerate a two-hour-old chart keeps working through a failed refresh, and one that cannot has an explicit flag to branch on.

How do I shard around the hard 100-rank cap?

Amazon publishes at most 100 ranks per chart, and no parameter changes that. The workaround is breadth, not depth: every browse node has its own independent top 100, so slug/nodeId entries multiply your coverage.

NODES = [
    "electronics/172623",      # Headphones
    "electronics/281052",      # Computers & Accessories
    "kitchen/289913",          # Coffee, Tea & Espresso
    "https://www.amazon.co.uk/gp/bestsellers/grocery",  # overrides country
]

payload = {"categories": NODES, "listType": "bestSellers",
           "country": "us", "maxResults": 100}

Four entries yield up to 400 ranked rows. A full-URL entry overrides both country and listType, so one run can span marketplaces — each row still tagged with its own marketplace, country, category_node_id and source_query.

How do I detect a partial refresh before I publish it?

Never promote a snapshot without a sanity gate. The actor reports rank from Amazon's own rank metadata rather than from render order, so rank is trustworthy, but a chart can still come back short if a follow-up fetch was dropped.

def acceptable(rows: list[dict], expected: int = 100) -> bool:
    ranks = sorted(r["rank"] for r in rows)
    return (len(rows) >= expected * 0.9
            and ranks[0] == 1
            and len(set(ranks)) == len(ranks))

Reject the batch and keep serving the previous snapshot if the gate fails. A zero-item result is a successful run with nothing to publish, not an error to page on.

Sample output

Each dataset item is one ranked product on one chart at one moment. The fields your feed will lean on are rank, asin, price, currency, rating, reviews_count and scraped_at; the *_text variants preserve exactly what Amazon displayed, which is useful when you need to render the original string instead of a parsed number.

[
  {
    "rank": 1,
    "asin": "B0DGHMNQ5Z",
    "title": "Amazon Fire TV Stick 4K streaming device",
    "price": 24.99,
    "price_text": "$24.99",
    "price_type": "list",
    "offers_count": null,
    "currency": "USD",
    "currency_symbol": "$",
    "rating": 4.6,
    "reviews_count": 82911,
    "product_url": "https://www.amazon.com/dp/B0DGHMNQ5Z",
    "image_url": "https://images-na.ssl-images-amazon.com/images/I/61yHqoTHqrL._AC_UL300_.jpg",
    "list_type": "bestSellers",
    "category_slug": "electronics",
    "category_node_id": null,
    "category_url": "https://www.amazon.com/gp/bestsellers/electronics",
    "page": 1,
    "marketplace": "www.amazon.com",
    "country": "us",
    "source_query": "electronics",
    "scraped_at": "2026-07-27T22:55:24+00:00"
  },
  {
    "rank": 57,
    "asin": "B09B8V1LZ3",
    "title": "Wireless Earbuds, Bluetooth 5.4 Headphones",
    "price": 19.99,
    "price_text": "2 offers from $19.99",
    "price_type": "from",
    "offers_count": 2,
    "currency": "USD",
    "currency_symbol": "$",
    "rating": null,
    "reviews_count": null,
    "product_url": "https://www.amazon.com/dp/B09B8V1LZ3",
    "list_type": "bestSellers",
    "category_slug": "electronics",
    "category_node_id": "172623",
    "page": 2,
    "marketplace": "www.amazon.com",
    "country": "us",
    "source_query": "electronics/172623",
    "scraped_at": "2026-07-27T22:55:31+00:00"
  }
]

Note price_type on the second row. When Amazon shows "N offers from $X", price is that lowest-offer figure and offers_count is populated — treat those two rows differently in any price comparison, or you will compare a buy-box price against a marketplace floor.

Common pitfalls

The 100-rank cap is Amazon's, not the actor's. Requesting a third page returns an error, so maxResults tops out at 100 per category. Shard across browse nodes instead.

Chart rows are not product detail. You get rank, ASIN, title, one price, rating, review count and image. No descriptions, bullets, variants, seller names, buy-box breakdown or stock levels. Pair the feed with a product-detail scraper if your app needs those.

New Releases coverage is genuinely thinner. Many products on that chart are days old, so ratings are frequently absent — a live UK New Releases chart returned ratings on 56% of rows. Design your schema for null, not for a parsing bug.

by_line and product_format are media-only. Books, Music and Video charts populate them on nearly every row; Electronics and Kitchen charts do not carry an author or format line at all.

Slugs are marketplace-specific. amazon.de uses computers where amazon.com uses electronics. An unknown slug produces a warning and zero rows for that entry, not a failed run — which is exactly why the acceptance gate above matters.

Charts are point-in-time. Two runs minutes apart can legitimately differ. Store snapshots; do not treat a single pull as ground truth.

The actor absorbs the parts you should not have to own: it validates that the chart payload is actually present rather than trusting a 200 status, retries with a fresh session on soft failures and rate limits, keeps whatever ranks it did collect when a follow-up call fails, and finishes cleanly on long multi-category runs.

Related use cases

The same dataset supports several adjacent jobs once your snapshot table exists:

Frequently asked questions

Is there an official Amazon Best Sellers API?

No. Amazon's Product Advertising API returns item attributes for ASINs you already know, and the SP-API is limited to your own seller account. Neither exposes the public top-100 chart, so the chart pages themselves are the only complete source.

How often should my Best Sellers feed refresh?

Amazon recomputes Best Sellers roughly hourly, so a cache TTL of 60 minutes matches the underlying data. Refreshing faster costs more and returns near-identical rows; refreshing daily is fine for trend series but will miss intra-day chart churn.

How do I get more than 100 products from one category?

You cannot. Amazon publishes at most 100 ranks per chart. Shard instead: pass subcategory browse nodes as separate entries in categories, since every browse node carries its own independent top 100.

Can one run mix marketplaces and list types?

Yes. A full chart URL in the categories array overrides both country and listType for that entry, so a single run can pull US Best Sellers and UK New Releases into one dataset, each row tagged with its own country and list_type.

Related

Try it yourself

100 free credits, no credit card.

About 30 real searches. Add the MCP to Claude or Cursor in two minutes.