Douyin Creator Video Research API Tutorial | SandBase
Find Douyin creators by keyword, read profiles, page posted videos, and rank them by plays and likes with four SandBase endpoints. No Douyin login; one API key.

You have a keyword and a question: which Douyin accounts own this topic, and which of their videos actually landed? A billboard shows what is hot across the whole platform. Creator research works the other way round. You pick a handful of accounts and read their back catalog. This Douyin Creator Video Research API tutorial does that with four SandBase endpoints: search creators by keyword, read a profile, page the posted videos, and rank them by plays and likes. It builds on the Douyin public data API hub; start there for the full endpoint map.
It reads public, read-only data. You need no Douyin login and no SDK, just a SandBase API key. The four endpoints are currently listed as Free in the SandBase catalog.
The endpoint API reference is the source of truth for parameters and the response envelope, and that envelope is all it guarantees. The payload field names below come from calls I ran (tested on 2026-10-01, UTC). Treat them as illustrative and observed-only, not documented guarantees.
Key takeaway
douyin/search/user-search-v2turns a keyword into creator candidates. Each result’suser_idwas asec_user_idin my runs, so it feeds the next two calls directly.douyin/app-v3/user-profilereturns the profile card: name, Douyin ID, bio, verification, followers, and total likes.douyin/app-v3/user-post-videospages the posted videos withmax_cursorandhas_more. In my runs it returned likes, comments, and shares, butplay_countwas always 0.douyin/app-v3/multi-video-statisticsfills in play counts for up to 50 video ids per call. Join onaweme_id, then rank.
Why play counts need a second call
My first version ranked videos straight from the post list. Every video showed play_count: 0. That held for all 60 posts across three pages on the magazine account I use below, and for the first page of a national news account I probed. Likes, comments, shares, and saves were all populated. The multi-video-statistics reference explains the gap. It says most Douyin endpoints no longer return a post’s play count, and that this endpoint is the way to get it. So the workflow adds a fourth call. It’s cheap in practice: one call covers 50 videos.
That changes how you design the ranking. Likes come from the post list and plays from the statistics call. Keep track of which number came from which call. In one batch, share_count also differed between the two (16 in the post list, 2 in the statistics response for the same video), so I take only play_count from the statistics call.
| Your need | Use |
|---|---|
| Public creator profiles and video engagement for research | SandBase Douyin public-data API |
| Publish, manage your own account, or licensed data | Douyin’s official open platform |
| Private, follower-only, or account-authorized data | Neither public workflow |
The workflow at a glance
- Search creators by keyword with
douyin/search/user-search-v2(keyword,cursor). - Read the profile with
douyin/app-v3/user-profile(sec_user_id). - Page posted videos with
douyin/app-v3/user-post-videos(sec_user_id,max_cursor,count). - Add play counts with
douyin/app-v3/multi-video-statistics(aweme_ids, comma-separated, up to 50). - Rank by plays, likes, or a like-to-play ratio.
The Douyin catalog on SandBase. It lists endpoints as GET /apis/v1/douyin/..., including app-v3/multi-video-statistics, and the side panel shows the selected endpoint as Available, Free. This tutorial calls the Model API POST /v1/api/douyin/... routes instead.
The catalog shows each operation as GET /apis/v1/douyin/<path>. This tutorial uses the Model API surface from the endpoint reference instead: POST /v1/api/douyin/<path> with a JSON body that holds only that endpoint’s parameters. If the reference’s generated example shows extra fields, follow the reference. 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",
}
def call(path: str, payload: dict, retries: int = 2) -> dict:
for attempt in range(retries + 1):
try:
resp = requests.post(f"{API}/{path}", headers=HEADERS, json=payload, timeout=90)
resp.raise_for_status()
break
except requests.RequestException:
if attempt == retries:
raise
time.sleep(2 * (attempt + 1))
body = resp.json()
if body.get("status") != "completed":
raise RuntimeError(body.get("error", {}).get("message", f"{path} did not complete"))
# The 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 {}
The reference documents completed responses as outputs[0].data. Every Douyin call in this test returned that shape. The helper also accepts a top-level output, because I have seen that on other SandBase platform endpoints. The nesting inside data varies by endpoint. Search wrapped its results in one more data key next to code. The profile, post-list, and statistics payloads did not. That’s why only step 1 reads .get("data") again. The retry is there because one of my screenshot requests to the same API host failed at the TLS layer and worked on the next try.
Step 1: search creators by keyword
def search_creators(keyword: str, pages: int = 1) -> list[dict]:
cursor, creators = 0, []
for _ in range(pages):
page = call("douyin/search/user-search-v2", {"keyword": keyword, "cursor": cursor})
data = page.get("data", {}) or {}
for u in data.get("user_list", []) or []:
creators.append({
"sec_user_id": u.get("user_id"),
"name": u.get("nick_name"),
"followers": u.get("fans_cnt"),
"total_likes": u.get("like_cnt"),
"posts": u.get("publish_cnt"),
})
if not data.get("has_more"):
break
cursor = data.get("cursor")
return creators
I searched for 中国国家地理 (Chinese National Geography, the magazine). Each page returned 30 accounts with has_more: true and cursor: 30. Sending that cursor back gave the second page (runs 563bdc12-2bbc-403f-b918-a1b964306571 and 4b610646-336c-49b7-9f64-3b35cac0da98). The first hit was the magazine’s main account with about 5.64 million followers. It was followed by its sub-brands (Landscape, Channel, Local Culture, and Explore) and a few unrelated science accounts.
Two details matter here. First, the field is called user_id, but its value looked like MS4wLjABAAAA..., which is the sec_user_id format the app-v3 endpoints ask for. Passing it straight to the profile call worked. Second, the reference page describes this endpoint as supporting fan-count and user-type filters, but its request schema lists only keyword and cursor. I didn’t send undocumented filters. If you need them, filter the returned list on fans_cnt yourself.
The douyin/search/user-search-v2 reference: POST /v1/api/douyin/search/user-search-v2 with optional cursor (default 0) and keyword. The documented response example leaves outputs[0].data empty.
Step 2: read the profile card
def read_profile(sec_user_id: str) -> dict:
user = call("douyin/app-v3/user-profile", {"sec_user_id": sec_user_id}).get("user", {}) or {}
return {
"name": user.get("nickname"),
"douyin_id": user.get("unique_id"),
"bio": user.get("signature"),
"verified_as": user.get("enterprise_verify_reason") or user.get("custom_verify"),
"followers": user.get("follower_count"),
"total_likes": user.get("total_favorited"),
"videos": user.get("aweme_count"),
}
For the magazine account (run 046c8eae-9a2c-411c-9914-3c8f5ed1f754), the profile came back as unique_id zggjdl, about 5.64 million followers, about 25.7 million total likes, and an enterprise verification naming the magazine’s publishing company. The video count didn’t match search, though. The profile said 538 videos (aweme_count), while search said 545 posts (publish_cnt). On the news account I probed earlier, the gap was much larger (about 12,300 versus 15,900). Treat both as approximate, and count the videos you actually page when the exact number matters.
Step 3: page the posted videos
def list_videos(sec_user_id: str, max_pages: int = 3) -> list[dict]:
max_cursor, videos = 0, []
for _ in range(max_pages):
page = call("douyin/app-v3/user-post-videos",
{"sec_user_id": sec_user_id, "max_cursor": max_cursor, "count": 20})
for a in page.get("aweme_list", []) or []:
stats = a.get("statistics", {}) or {}
videos.append({
"aweme_id": a.get("aweme_id"),
"created": a.get("create_time"),
"desc": (a.get("desc") or "")[:60],
"likes": stats.get("digg_count"),
"comments": stats.get("comment_count"),
"shares": stats.get("share_count"),
"saves": stats.get("collect_count"),
})
if not page.get("has_more"):
break
max_cursor = page.get("max_cursor")
return videos
The reference says the first page uses max_cursor 0 and later pages send the max_cursor from the previous response. The responses agreed. Each page returned 20 items, has_more: 1, and a max_cursor that looked like a millisecond timestamp. create_time on each video was in seconds. Here is a trimmed excerpt of the first page (run 28936f25-f639-471c-9f9b-1204e408db4c):
{
"has_more": 1,
"max_cursor": 1789444800000,
"aweme_list": [
{
"aweme_id": "7690953381636017446",
"desc": "【预告片】生命奇观2 川西山地 ...",
"create_time": 1790740800,
"is_top": 0,
"statistics": {
"play_count": 0,
"digg_count": 845,
"comment_count": 34,
"share_count": 16,
"collect_count": 43
}
}
]
}
That play_count: 0 is the gap from the top of this article. The page also mixed formats: some items had aweme_type: 68, which appeared to be image posts, next to regular videos. The statistics endpoint returned play counts for those too. If you only want videos, filter on aweme_type after checking a few items yourself. The reference also lists an optional sort_type, but its description ends at “optional values are as follows:” without listing them, so I left it unset. The web/user-post-videos sibling exists, but this tutorial uses the app-v3 route because that’s the one I verified.
The douyin/app-v3/user-post-videos reference: required sec_user_id, plus optional count (20 or fewer), max_cursor, and sort_type. The sort_type description stops before listing any values.
Step 4: add play counts and rank
def add_play_counts(videos: list[dict]) -> list[dict]:
plays = {}
for i in range(0, len(videos), 50): # the reference allows up to 50 ids per call
ids = ",".join(v["aweme_id"] for v in videos[i:i + 50])
stats = call("douyin/app-v3/multi-video-statistics", {"aweme_ids": ids})
for s in stats.get("statistics_list", []) or []:
plays[str(s.get("aweme_id"))] = s.get("play_count")
for v in videos:
v["plays"] = plays.get(str(v["aweme_id"]))
return videos
def rank(videos: list[dict], key: str = "plays", top: int = 5) -> list[dict]:
return sorted(videos, key=lambda v: v.get(key) or 0, reverse=True)[:top]
In my runs, statistics_list held one item per id, each with aweme_id, play_count, digg_count, share_count, and sometimes download_count. The same image post that showed 0 plays in the post list came back with 70,966 plays here (run dc35d090-ba7a-4391-a4f4-da78349bc42b). The join uses string ids, because both responses returned aweme_id as a string and string keys are safer than numbers this large.
The douyin/app-v3/multi-video-statistics reference: one required aweme_ids string, comma-separated, 50 ids or fewer. The description says most Douyin interfaces no longer return play counts. The description also contains a per-call “Price” line; the SandBase catalog currently lists this endpoint as Free, so check the catalog before production use.
Putting it together
creators = search_creators("中国国家地理", pages=2)
target = creators[0]
profile = read_profile(target["sec_user_id"])
videos = add_play_counts(list_videos(target["sec_user_id"], max_pages=3))
for v in rank(videos, "plays"):
rate = (v["likes"] or 0) / v["plays"] if v.get("plays") else 0
print(f"{v['plays']:>12,} plays {v['likes']:>10,} likes {rate:.1%} {v['desc'][:30]}")
I ran the full script verbatim. It made two search calls, one profile call, three post-list calls (1eca7db3-03b1-4af2-866b-30e69a24c249, 3042978c-8207-4915-9fea-a6e6c5d17c1c, d4a0422d-d0d3-409a-b057-4e3600902272), and two statistics calls for the 60 videos (c67a86e8-4129-4205-8790-fa4510bc1b2c, 6ab3f851-f942-436f-8121-0fb222cb77fa). The 60 items covered posts from early January to the end of September 2026. The ranking was lopsided. The top two videos had about 90.4 million and 87.0 million plays, and the third about 11.4 million. The median across all 60 was about 374,000. Among the top five, like-to-play ratios ran from about 1.5% to 4.1%. That’s the useful finding: an account’s average hides a few breakout videos. Plays tell you which topics got distribution, and the like ratio tells you which ones people actually liked.
For several creators, loop over the search shortlist and keep one record per sec_user_id, holding the profile and the ranked videos. A creator with three pages costs five calls: profile, three post-list pages, and one statistics call. Hand those records to a model and ask it to compare hit rates, name each creator’s best-performing themes, or flag accounts whose plays depend on a single viral post.
Documented vs. observed
| Item | Status |
|---|---|
POST /v1/api/douyin/search/user-search-v2 with keyword, cursor | Documented in the reference |
POST /v1/api/douyin/app-v3/user-profile with sec_user_id | Documented in the reference |
POST /v1/api/douyin/app-v3/user-post-videos with sec_user_id, max_cursor, count | Documented in the reference |
POST /v1/api/douyin/app-v3/multi-video-statistics with aweme_ids (50 or fewer) | Documented in the reference |
Envelope id / status / model / outputs[0].data | Documented in the reference |
Search data.user_list[] with user_id (sec_user_id format), nick_name, fans_cnt; cursor, has_more | Observed only |
Profile user.unique_id, follower_count, total_favorited, aweme_count, enterprise_verify_reason | Observed only |
Post list aweme_list[].statistics, has_more, max_cursor; play_count returned as 0 | Observed only |
Statistics statistics_list[] with aweme_id, play_count, digg_count | Observed only |
Common use cases
Creator shortlisting for a campaign
Search a category keyword, read the profiles, and rank candidates by median plays across their recent videos, not by follower count. Input: a keyword. Output: a ranked creator table. Endpoints: all four.
Content audit of an official account
Page a brand or media account’s recent posts and find which themes break out. Input: one sec_user_id. Output: videos ranked by plays and like ratio. Endpoints: user-post-videos, multi-video-statistics.
Sub-brand comparison
Media groups often run several accounts, as the search results showed. Compare plays per post across the main account and its sub-brands. Input: the search shortlist. Output: plays per account. Endpoints: all four.
Tracking a video after launch
Re-run the statistics call on a fixed list of aweme_ids every day and store the counts. Input: up to 50 ids per call. Output: a play-count time series. Endpoint: multi-video-statistics.
Practical notes
- Don’t rank on the post list’s
play_count. It was 0 in every call I made. Get plays frommulti-video-statistics. - Take each metric from one call.
share_countdisagreed between the two endpoints for the same video. - Page with the returned cursor. Send back
cursorfor search andmax_cursorfor the post list, and stop whenhas_moreis false. - Expect mixed formats. Image posts appeared next to videos in the post list.
- Counts are approximate. Profile
aweme_countand searchpublish_cntdisagreed: by 7 posts on the magazine account, by about 3,600 on the news account. - Business fields are observed-only. Check them against a live response before you depend on them.
- Stick to public accounts. For research, store account-level and video-level metrics, not personal data about individual viewers or commenters.
FAQ
Do I need a Douyin account or login?
No. You authenticate to SandBase with SANDBASE_API_KEY. These read endpoints need no Douyin account or OAuth on your side.
Is it free? The four endpoints are currently listed as Free in the SandBase catalog. Check the catalog for the current status.
Where does the sec_user_id come from?
From search. In my runs, each user_list item’s user_id was already in sec_user_id format.
Why are play counts zero?
The post list returned play_count: 0 in my tests. The multi-video-statistics reference says most Douyin interfaces no longer return play counts. Call it with up to 50 ids.
How is this different from billboard monitoring? The billboard tutorial watches what is trending across Douyin. This one starts from accounts you pick and reads their own catalog.
Wrap up
A keyword is enough to research Douyin creators. Search gives you the account ids, the profile gives you the card, the post list gives you the catalog and engagement, and one statistics call adds the play counts the post list leaves at zero. Join on aweme_id and you can rank any creator’s videos by reach and reaction. For the rest of the Douyin endpoints, see the Douyin public data API hub. When you’re ready: