Blog/Developer Tools/

TikTok Shop Product Research API Tutorial | SandBase

Evaluate a TikTok Shop niche: search products, read ratings and reviews, and list the seller's catalog with four SandBase endpoints. No TikTok login; one API key.

Dark cinematic render of a grid of blank product boxes, one lifted into a glass product card with five stars, feeding review bubbles and a shop shelf into an agent core

My first three keyword searches against the TikTok Shop US market came back with products: [] and a success message. No error, no hint. A later call with the first keyword, “coffee”, returned 30 listings and a page token. That was the first thing I learned about product research on TikTok Shop through an API: an empty page is not an answer, and your code has to know the difference.

This TikTok Shop Product Research API tutorial is for developers and analysts who want to size up a product niche before committing to it. You give it a keyword. It searches public listings, picks the best seller, reads that product’s rating spread and a sample of review text, and lists the rest of the seller’s catalog. Four SandBase endpoints cover the whole chain, and an agent can run it end to end. For the full TikTok endpoint map, start with the TikTok public data API hub.

All of this is public, read-only data. You need no TikTok account or SDK, only a SandBase API key. The shop endpoints used here are currently listed as Free in the SandBase catalog.

The endpoint reference is the source of truth for parameters and the response envelope, and it guarantees only that envelope. Every payload field name below comes from calls I ran (tested on 2026-10-01, UTC). Treat them as illustrative and observed-only, not documented guarantees.

Key takeaway

  • tiktok/shop-web/search-products-list returns listing cards with price, sold count, rating, brand, and seller_id. Page it with the returned offset and page_token.
  • product-detail-v2 adds the category path and the full star histogram, but in my runs it did not return the product’s own price or title. Take those from the search card.
  • product-reviews-v2 pages review text by page_start. seller-products-list pages a shop’s catalog with an opaque search_params cursor.
  • In my tests, search worked for US, SG, and MY, while detail and reviews only worked for US. Empty search pages happened often enough that the code retries them.

Which endpoints held up

The shop-web family has more endpoints than this chain uses, so I probed each one before designing anything. Here is what happened, because it shapes the whole tutorial:

EndpointWhat I saw
search-products-listWorked for US, SG, MY. US returned empty pages intermittently
search-products-list-v2Returned an empty, more deeply nested result for US; one SG call failed with an upstream 400
product-detail (v1)Completed, but the product’s own record had an empty seller_id and no price
product-detail-v2Worked for US: rating histogram, category path, more-from-shop list
product-detail-v3Upstream 400 on every attempt, including the reference’s example id
product-reviews-v2Worked for US; upstream 400 for SG and MY products
seller-products-listWorked for US and SG, with cursor paging
products-by-category-idUpstream 400 for every category id I tried, including ids from products-category-list

So “browse a category” is out for now, and the chain is search-first. One more surprise: the reference lists GB as a region, but a GB search came back with region_supported: false and a supported_regions list of ID, JP, MX, MY, PH, SG, TH, US, and VN (run 30cb8c1c-f1d0-426d-8d8e-674de98f7751). Check that field before you trust a region.

Your needUse
Public listings, ratings, reviews, and shop catalogs for researchSandBase TikTok Shop public-data API
Selling, managing your own shop, orders, or affiliate dataTikTok Shop’s official seller and partner APIs
Private buyer or account-only dataNeither public workflow

The workflow at a glance

  1. Search the keyword with search-products-list (search_word, region, offset, page_token). Retry empty pages.
  2. Rank the listings by sold count and note how many have zero sales.
  3. Read product signals with product-detail-v2 (product_id, region): category path and star histogram.
  4. Sample review text with product-reviews-v2 (product_id, page_start).
  5. List the seller’s catalog with seller-products-list (seller_id, search_params).

SandBase TikTok API catalog page listing TikTok endpoints, with the selected endpoint marked Available and Free The TikTok catalog on SandBase. It shows 145 endpoints as GET /apis/v1/tiktok/... paths, and the selected one is marked Available and Free. This tutorial calls the Model API POST /v1/api/tiktok/... routes instead.

A note on surfaces before the code. The catalog page lists each operation as GET /apis/v1/tiktok/<path>. This tutorial uses the Model API surface from the endpoint reference, which is POST /v1/api/tiktok/<path> with a JSON body of endpoint parameters. Keep the POST method and the /v1/api/ prefix when you copy the examples.

Step 0: one helper for every call

import os
import time
import requests

API = "https://api.sandbase.ai/v1/api"
HEADERS = {
    "Authorization": f"Bearer {os.environ['SANDBASE_API_KEY']}",
    "Content-Type": "application/json",
}
REGION = "US"


def call(path: str, payload: dict, retries: int = 2) -> dict:
    for attempt in range(retries + 1):
        resp = requests.post(f"{API}/{path}", headers=HEADERS, json=payload, timeout=90)
        if resp.status_code >= 500 and attempt < retries:
            time.sleep(2 * (attempt + 1))  # upstream hiccup: back off and retry
            continue
        resp.raise_for_status()
        body = resp.json()
        if body.get("status") != "completed":
            raise RuntimeError(body.get("error", {}).get("message", f"{path} did not complete"))
        # Reference documents outputs[0].data; also accept a top-level `output`.
        output = body.get("output")
        if output is None and body.get("outputs"):
            output = body["outputs"][0].get("data", {})
        return output or {}
    raise RuntimeError(f"{path} kept failing")


def listing(p: dict) -> dict:
    price = p.get("product_price_info") or {}
    seller = p.get("seller_info") or {}
    rate = p.get("rate_info") or {}
    return {
        "product_id": str(p.get("product_id")),
        "title": p.get("title"),
        "brand": (p.get("brand_info") or {}).get("brand_name"),
        "seller_id": seller.get("seller_id"),
        "shop": seller.get("shop_name"),
        "price": price.get("sale_price_decimal"),
        "currency": price.get("currency_name"),
        "sold": (p.get("sold_info") or {}).get("sold_count"),
        "score": rate.get("score"),
        "reviews": rate.get("review_count"),
    }

The reference documents completed responses as outputs[0].data, and every shop call I made returned that shape. The helper also reads a top-level output, because other SandBase platform endpoints have returned it. Inside outputs[0].data, the shop routes wrapped the payload in another data key next to code and message, which is why each step reads .get("data") again.

Failures looked different from what I expected. A bad region or an unsupported product came back as HTTP 503 with upstream error 400 in the body, not as a 4xx. The helper retries 5xx twice and then raises, so a region mismatch fails after a few seconds instead of looping. The listing() function normalizes a product card. Search, the more-from-shop list, and the seller catalog all used the same card shape in my runs, so one parser covers all three.

Step 1: search the niche and retry empty pages

def search_products(word: str, pages: int = 2, empty_retries: int = 2) -> list[dict]:
    rows, offset, token = [], 0, ""
    for _ in range(pages):
        payload = {"search_word": word, "region": REGION, "offset": offset, "page_token": token}
        for _ in range(empty_retries + 1):
            data = call("tiktok/shop-web/search-products-list", payload).get("data", {})
            if data.get("products"):
                break  # an empty page here was usually transient, so retry it
        rows += [listing(p) for p in data.get("products", []) or []]
        more = data.get("load_more_params") or {}
        if not data.get("has_more") or not more.get("page_token"):
            break
        offset, token = more.get("offset", 0), more["page_token"]
    return rows

Paging follows the response. The first page for “mushroom coffee” (run 5bb26751-92ef-453c-8766-75dde7b8a575) returned 30 listings, has_more: true, and load_more_params with offset: 30 and a page_token. I sent both back, and the second page (run 694682ab-8809-4b83-837a-9c58f076dbd7) returned 30 more with no overlap. Here is a trimmed card for the brand-owned Micro Ingredients shop from another run (7f1495f2-9bac-4c2c-9132-2007d910d0ef):

{
  "has_more": true,
  "load_more_params": {"api_source": 2, "offset": 30, "page_token": "20261001031049DC4BD36BAFE739165F2A"},
  "products": [
    {
      "product_id": "1729385057785057965",
      "title": "Micro Ingredients Organic Instant 10 in 1 Mushroom Coffee Powder",
      "brand_info": {"brand_name": "Micro Ingredients"},
      "sold_info": {"sold_count": 219808},
      "rate_info": {"review_count": "28866", "score": 4.7},
      "product_price_info": {
        "currency_name": "USD",
        "sale_price_decimal": "24.95",
        "origin_price_decimal": "27.95"
      },
      "seller_info": {"seller_id": "7494949083499694765", "shop_name": "Micro Ingredients"}
    }
  ]
}

Two type quirks. Prices are decimal strings, and review_count is a string while sold_count is an integer, so cast before you compare. And the counts move. The same product showed 28,866 reviews on this card and 29,017 in another run the same day. Store the run time with every number.

The empty pages are the real gotcha. For “mushroom coffee” in the US, two back-to-back calls returned products: [] with has_more: false (runs dfafb1ab-debd-48d7-97ae-f758a56d3817 and 1e5f5e65-f911-4705-8063-625b699e5474), and the next one returned 30 listings. An empty result can also be a real “no matches”, so after the retries the code accepts it rather than spinning.

SandBase API reference for the TikTok Shop search-products-list endpoint The tiktok/shop-web/search-products-list reference: POST /v1/api/tiktok/shop-web/search-products-list with a required search_word and optional offset, page_token, and region. The response example leaves outputs[0].data empty.

Step 2: read the rating spread and category

def product_signals(product_id: str) -> dict:
    data = call("tiktok/shop-web/product-detail-v2",
                {"product_id": product_id, "region": REGION}).get("data", {})
    comp = {c.get("component_name"): c.get("component_data") or {}
            for c in data.get("components_map", []) if isinstance(c, dict)}
    ratings = (comp.get("product_info", {}).get("reviews_info") or {}).get("review_ratings", {})
    crumbs = comp.get("bread_crumbs", {}).get("bread_crumbs", []) or []
    return {
        "category_path": [b.get("name") for b in crumbs][1:],  # drop the "TikTok Shop" root
        "score": ratings.get("overall_score"),
        "review_count": ratings.get("review_count"),
        "star_histogram": ratings.get("rating_result"),
        "more_from_shop": [listing(p) for p in comp.get("feed_list_more_from", {}).get("products", []) or []],
    }

The detail route doesn’t return a product object. It returns a page layout: a components_map list of named components, several of them null. In my runs the product_detail component itself carried skip_data_return: true and no data. That’s why the price and title come from the search card. What product-detail-v2 did give me (run 644c0d78-6d14-49ea-988b-f3b08d36d161) was useful:

  • bread_crumbs: Food & Beverages, then Drinks, then Coffee
  • product_info.reviews_info.review_ratings: overall_score: 4.7, a rating_result histogram with 25,363 five-star and 1,195 one-star reviews, and review_count: "29017"
  • feed_list_more_from: other products from the same shop, as product cards
  • related_link: related search phrases

The payload was large. On another product the response was over 1 MB, mostly recommendation cards. Parse what you need and drop the rest before you hand anything to a model.

Step 3: sample the review text

def sample_reviews(product_id: str, pages: int = 2) -> list[dict]:
    reviews = []
    for page in range(1, pages + 1):
        data = call("tiktok/shop-web/product-reviews-v2",
                    {"product_id": product_id, "region": REGION, "page_start": page}).get("data", {})
        for r in data.get("product_reviews", []) or []:
            if r.get("review_text"):
                # keep rating and text only; drop reviewer name, id, and avatar
                reviews.append({
                    "rating": r.get("review_rating"),
                    "verified": r.get("is_verified_purchase"),
                    "variant": r.get("sku_specification"),
                    "text": r["review_text"],
                })
        if not data.get("has_more"):
            break
    return reviews

The reference documents page_start as a page number that starts at 1. In my runs (2cd6b7b5-b41c-4197-bfcb-247ef1418e06 for page 1 and 22fd29c7-f3d9-485f-95e5-2e0b4312bbeb for page 2), each page held up to 20 reviews with has_more: true. In an earlier two-page test on a different product, the review ids didn’t overlap. Some reviews have a rating and an empty review_text, so the helper skips those.

Each review also carried a masked reviewer name, a reviewer id, and an avatar URL. Niche research doesn’t need any of them, so the helper drops them before storage. What’s left is rating, verified-purchase flag, variant (for example 28oz or 14oz), and text.

I couldn’t pin down the filter parameters. The reference lists filter_type (default 1) and filter_value (described as a star filter, default 6). A call with filter_type: 2, filter_value: 1 still returned mostly five-star reviews (run 0d3b40e6-a885-456a-84cd-cd6ceef5e7d6). I stayed on the defaults and use the histogram from step 2 for the star mix.

SandBase API reference for the TikTok Shop product-reviews-v2 endpoint The tiktok/shop-web/product-reviews-v2 reference: a required product_id, plus optional filter_type, filter_value, page_start, region (default US), and sort_rule.

Step 4: list the seller’s catalog

def seller_catalog(seller_id: str, pages: int = 2) -> list[dict]:
    items, cursor = [], ""
    for _ in range(pages):
        data = call("tiktok/shop-web/seller-products-list",
                    {"seller_id": seller_id, "region": REGION, "search_params": cursor}).get("data", {})
        items += [listing(p) for p in data.get("products", []) or []]
        cursor = (data.get("load_more_params") or {}).get("search_params")
        if not data.get("has_more") or not cursor:
            break
    return items

The cursor is opaque. Page 1 (run ac77c278-b76c-4921-9eda-73737e8ac823) returned 30 products and a load_more_params.search_params string starting with 30_. Page 2 (run c37a49bf-3b09-43f0-b7bd-173cb0643db0) took that string and returned 30 more. Send it back unchanged. The -v2 variant of this endpoint names its parameter searchParams and returned the cursor as next_search_param inside a component_data wrapper, so the two versions don’t swap in cleanly.

SandBase API reference for the TikTok Shop seller-products-list endpoint The tiktok/shop-web/seller-products-list reference: a required seller_id and an optional search_params described as pagination parameters.

Putting it together: a niche report

def research_niche(word: str, top_n: int = 1) -> dict:
    results = search_products(word)
    ranked = sorted(results, key=lambda r: r["sold"] or 0, reverse=True)
    report = {"keyword": word, "listings_seen": len(results),
              "zero_sales_share": round(sum(1 for r in results if not r["sold"]) / max(len(results), 1), 2),
              "products": []}
    for top in ranked[:top_n]:
        signals = product_signals(top["product_id"])
        catalog = seller_catalog(top["seller_id"]) if top["seller_id"] else []
        report["products"].append({
            **top,
            **{k: v for k, v in signals.items() if k != "more_from_shop"},
            "review_sample": sample_reviews(top["product_id"]),
            "shop_listings_read": len(catalog),
            "shop_top_sellers": sorted(catalog, key=lambda p: p["sold"] or 0, reverse=True)[:3],
        })
    return report

I ran research_niche("mushroom coffee") verbatim. It made seven calls: two search pages, one detail, two catalog pages, and two review pages. The report saw 60 listings, and 27% of them had zero sales. The top seller was Micro Ingredients’ mushroom coffee at $24.95 with 219,808 sold and a 4.7 score. Its category path was Food & Beverages, Drinks, Coffee.

The shop catalog told a different story from the keyword. The shop’s three best sellers in the 60 listings I read were a collagen powder (1,377,502 sold), a vitamin D3 K2 supplement, and an oregano oil supplement. Mushroom coffee is a side line for this seller, not its core business. That’s the kind of context a keyword search alone can’t give you.

The zero-sales share is worth a look too. In the “coffee” and “mushroom coffee” results, several listings used a leading brand’s name in the title but came from unrelated shops with zero to five sales. Compare brand with shop before you count a listing as competition.

Documented vs. observed

ItemStatus
POST /v1/api/tiktok/shop-web/search-products-list with search_word, offset, page_token, regionDocumented in the reference
POST /v1/api/tiktok/shop-web/product-detail-v2 with product_id, region, seller_idDocumented in the reference
POST /v1/api/tiktok/shop-web/product-reviews-v2 with product_id, page_start, filtersDocumented in the reference
POST /v1/api/tiktok/shop-web/seller-products-list with seller_id, search_paramsDocumented in the reference
Envelope id / status / model / outputs[0].dataDocumented in the reference
Card fields sold_info, rate_info, product_price_info, seller_info, brand_infoObserved only
has_more, load_more_params.offset / page_token / search_paramsObserved only
components_map, bread_crumbs, review_ratings.rating_resultObserved only
Region support (US for detail and reviews; GB unsupported)Observed only

Common use cases

Niche sizing before you source

Run the report across a handful of keywords and compare sold counts on the top listings, the zero-sales share, and score spread. Input: keywords. Output: one report per niche. Endpoints: search-products-list, product-detail-v2.

Review mining for product gaps

Pull a few pages of review text for the top two or three products and let a model group recurring complaints by variant. Input: product ids. Output: themed review samples. Endpoint: product-reviews-v2.

Competitor shop mapping

For each top seller, read the catalog to see what else the shop sells and which lines carry it. Input: seller ids. Output: ranked catalogs. Endpoint: seller-products-list.

Creator-to-product follow-up

If you already track the creators who promote a category, pair this with the TikTok creator research tutorial and research the products their videos point to.

Practical notes

  • Treat empty search pages as suspect. Retry them a couple of times before you conclude there are no results.
  • Check region support per endpoint. Detail and reviews worked only for US in my tests, and GB search reported region_supported: false.
  • Price and title come from the card. product-detail-v2 skipped its own product component in my runs.
  • Follow the cursors you’re given. offset plus page_token for search, page_start for reviews, search_params for catalogs.
  • Normalize types and timestamps. Counts arrive as a mix of strings and integers, and they drift between calls.
  • Drop reviewer identity. Keep rating and text only.
  • Public, read-only data only. These calls don’t touch orders, shop management, or buyer data.
  • Product links work as an entry point too. product-id-by-share-link turned a shop.tiktok.com/us/pdp/... URL into its product_id (run 6158dfd3-5dce-4456-bd80-80fe450b237b). I didn’t test short share links.

Scope of testing: two US niches and a handful of SG and MY searches on one day. I haven’t load-tested any of this, and I can’t say how often the empty-page behavior happens at other times.

FAQ

Do I need a TikTok or TikTok Shop account? No. You authenticate to SandBase with SANDBASE_API_KEY. These read endpoints need no TikTok login or OAuth on your side.

Is it free? The shop endpoints used here are currently listed as Free in the SandBase catalog. Check the catalog for the current status.

Can I browse by category instead of keyword? products-category-list returned the category tree, but products-by-category-id failed upstream for every id I tried. Use keyword search for now.

Why not product-detail-v3? It failed upstream on every attempt in my tests, including the reference’s example id. product-detail-v2 worked for US products.

Does this work outside the US? Search worked for SG and MY. Detail and reviews failed for the SG and MY products I tried, so the full chain only ran for US.

Wrap up

A keyword is enough to get a first read on a TikTok Shop niche: listings with price and sold counts, the star mix and review text of the leader, and the rest of that seller’s catalog. Build in retries for empty search pages and check region support, and the chain runs as a single agent task. For the rest of the TikTok endpoints, see the TikTok public data API hub. When you’re ready: