Douyin Xingtu KOL Evaluation API Tutorial | SandBase
Compare Douyin creators before a brand deal: resolve a Xingtu kolId, then read base info, fans portrait, video performance, and the rate card with SandBase.

This Douyin Xingtu KOL Evaluation API tutorial starts from a common request. A brand team sends you three Douyin accounts and one question: which one should get the sponsored video? Follower counts alone don’t answer that. You need to know who actually follows each account, how recent videos performed, how sponsored videos compare with organic ones, and what each creator charges. On Douyin, that commercial view lives in Xingtu (Ocean Engine Xingtu), the platform’s official creator-marketing marketplace. This tutorial shows how to pull the Xingtu view for a shortlist of creators with SandBase endpoints and line the candidates up side by side, so an agent or analyst can draft the recommendation.
It builds on the Douyin public data API hub. If you want a creator’s posted videos and play counts rather than the commercial view, the Douyin creator video research tutorial covers that path. Here the focus is the brand-deal decision.
You need no Douyin or Xingtu account, but you authenticate with a SandBase API key. The Douyin endpoints used here, Xingtu routes included, are currently listed as Free in the SandBase catalog. The Xingtu reference descriptions also contain a per-call price line and, on the kol detail routes, a note about an enterprise account. The catalog is where SandBase lists the current status, so check it before production use.
The reference pages guarantee only the response 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
- Xingtu endpoints take a
kolId, not a Douyinsec_user_id. Resolve it first withdouyin/xingtu/xingtu-kolid-by-sec-user-id.kol-base-info-v1,kol-fans-portrait-v1, andkol-video-performance-v1give the creator’s categories, follower makeup, and recent organic vs. sponsored plays.kol-service-price-v1returns the Xingtu rate card, andkol-cp-info-v1returns expected views plus CPM and CPE estimates.- I tested three public media brands (Rishiji, Chinese National Geography, and Yitiao). All three resolved, and every detail call completed. One account returned empty performance summaries, so the code falls back to raw video plays.
Why Xingtu data, not only the public profile
A public Douyin profile tells you followers and likes. A brand deal needs different numbers: the follower makeup (age, city tier, consumer segment), the play median of sponsored videos, and the listed price for each video format. Xingtu exposes these to advertisers, and the SandBase douyin/xingtu family reads them by kolId.
| Your need | Use |
|---|---|
| Shortlist and compare creators with public Xingtu data | SandBase douyin/xingtu endpoints |
| Place an order, sign a contract, or brief a creator | Xingtu itself (official advertiser account) |
| Private campaign results or account-only data | Neither public workflow |
This is research, not ordering. SandBase gives you read access to the listing data. Placing a Xingtu order still happens on Xingtu.
The workflow at a glance
- Find the account with
douyin/search/user-search-v2(keyword,cursor) and keep itssec_user_id. - Resolve the Xingtu
kolIdwithdouyin/xingtu/xingtu-kolid-by-sec-user-id(sec_user_id). - Read base info with
douyin/xingtu/kol-base-info-v1(kolId,platformChannel). - Read the fans portrait with
douyin/xingtu/kol-fans-portrait-v1(kolId). - Read recent video performance with
douyin/xingtu/kol-video-performance-v1(kolId,onlyAssign). - Read the rate card and cost estimates with
douyin/xingtu/kol-service-price-v1(kolId,platformChannel) anddouyin/xingtu/kol-cp-info-v1(kolId).
The Douyin catalog on SandBase. It shows 262 endpoints as GET /apis/v1/douyin/..., and the selected endpoint (general search V1) is marked 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>. The code below uses the Model API surface from the endpoint reference: POST /v1/api/douyin/<path> with a JSON body that holds only the endpoint’s 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 statistics
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"))
# Documented shape is 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", {})
output = output or {}
status = (output.get("base_resp") or {}).get("status_code", 0)
if status != 0:
raise RuntimeError(f"{path}: base_resp.status_code={status}")
return output
The reference documents completed responses as outputs[0].data. Every call in my final run returned that shape. The helper still reads a top-level output because other SandBase platform endpoints have returned it, so the code survives either envelope.
Two more details came from testing. Every Xingtu payload carried a base_resp object with status_code: 0, so the helper treats a non-zero value as a failure. And the retry is not decoration. In the run I quote below, one kol-fans-portrait-v1 call came back as an HTTP error saying “upstream error 400: Request failed. Please retry”. The second attempt completed (run e5c76e47-6ace-47d8-b2ef-317fef873e74).
Step 1: resolve the Xingtu kolId
def resolve_kol(keyword: str) -> dict:
found = call("douyin/search/user-search-v2", {"keyword": keyword, "cursor": 0})
users = (found.get("data") or {}).get("user_list") or []
exact = [u for u in users if u.get("nick_name") == keyword]
if len(exact) != 1:
# no silent fallback: zero or several exact matches need a human decision
raise LookupError(f"{keyword}: {len(exact)} exact nickname matches; confirm the account first")
sec_user_id = exact[0]["user_id"] # holds a sec_user_id (MS4wLjABAAAA...)
kol = call("douyin/xingtu/xingtu-kolid-by-sec-user-id", {"sec_user_id": sec_user_id})
if not kol.get("id"):
raise LookupError(f"{keyword} is not registered on Xingtu")
return {"keyword": keyword, "sec_user_id": sec_user_id,
"kol_id": kol["id"], "nick_name": kol.get("nick_name"), "tag": kol.get("tag")}
For Rishiji (日食记), a food video brand, the search (run 2bd433e3-58f2-445c-93e8-9299d9d2071d) returned user_list entries whose user_id held a sec_user_id. The resolver (run 44a9f085-3832-4b92-a183-d5259d43e059) returned the Xingtu record:
{
"id": "6677767167234015243",
"core_user_id": "64117131714",
"nick_name": "日食记",
"tag": "美食",
"base_resp": {"status_code": 0, "status_message": "Success"}
}
That id is the kolId every later call needs. For Chinese National Geography (中国国家地理) and Yitiao (一条), the same pattern worked (runs a6517c08-7b7d-4c19-b932-f83f4c578a8c and e2c448b7-33ed-49d9-9864-cebad3305633).
Why not search Xingtu directly? douyin/xingtu/search-kol-v2 takes a keyword and returns authors with the kolId already attached. It works, but for the Yitiao keyword its first page ranked other creators above the media account. Its request schema also has no page parameter, even though the response carried a pagination object with has_more. Searching Douyin users and matching the exact nickname was more predictable. The xingtu-kolid-by-uid and xingtu-kolid-by-unique-id siblings work the same way if you already hold those ids.
The xingtu-kolid-by-sec-user-id reference: POST /v1/api/douyin/xingtu/xingtu-kolid-by-sec-user-id with one required sec_user_id. The documented response example leaves outputs[0].data empty.
Step 2: base info and fans portrait
def base_info(kol_id: str) -> dict:
b = call("douyin/xingtu/kol-base-info-v1", {"kolId": kol_id, "platformChannel": "_1"})
return {
"followers": b.get("follower"),
"tags_level_two": b.get("tags_level_two"), # JSON string in my runs
"content_themes": (b.get("content_theme_labels") or [])[:5],
"has_mcn": bool(b.get("mcn_id") and b.get("mcn_id") != "0"),
"is_star": b.get("is_star"),
}
def fans_portrait(kol_id: str) -> dict:
p = call("douyin/xingtu/kol-fans-portrait-v1", {"kolId": kol_id})
return {d.get("type_display"): d.get("description")
for d in p.get("distributions", []) if d.get("description")}
platformChannel is required, but the reference description stops at “supports the following parameters:” with no list. I sent "_1", the Douyin short-video channel. A test with "1" returned the same record. I didn’t confirm other values, so stay with _1 unless the reference adds a list.
The base record carried follower, content_theme_labels, tags_level_two, and MCN fields. Note that tags and tags_level_two arrived as JSON strings, not arrays. The record also includes an MCN introduction, which can contain business contact details. The helper keeps only a yes/no flag, and I’d keep it that way in a shared report.
The fans portrait returns a distributions list. Each item has a type_display label, a raw distribution_list of key/count pairs, and a one-line description. For Rishiji (run e5c76e47-6ace-47d8-b2ef-317fef873e74), a trimmed excerpt:
{
"distributions": [
{"type_display": "年龄分布", "description": "24到30岁居多,占比30%"},
{"type_display": "城市等级分布", "description": "一线居多,占比37%"},
{"type_display": "八大人群分布", "description": "新锐白领居多,占比26%"},
{"type_display": "性别分布", "description": "男性居多,占比50%"}
]
}
The descriptions are handy for a model prompt, but they round hard. The gender line says “mostly male” at 50%. The raw counts in that call’s sibling endpoint (xingtu-v2/author-fans-distribution, run 82bcca0d-4e4b-4b8f-9ca8-75ca62aabdad) were 6,169,425 male vs 6,155,171 female, which is effectively even. If gender skew matters for the brief, compute it from distribution_list.
There’s also kol-audience-portrait-v1, which describes viewers rather than followers. For Rishiji (run 1d0456b8-5e68-48ce-9a45-0aac0f434471) it said viewers skewed younger, with 18–23 as the largest age band (32%) and Gen Z as the top segment. The followers skewed 24–30 and “emerging white-collar.” That gap is useful: it tells you who sees the videos vs. who subscribed.
Step 3: recent video performance
def video_performance(kol_id: str) -> dict:
v = call("douyin/xingtu/kol-video-performance-v1", {"kolId": kol_id, "onlyAssign": False})
desc = v.get("data_description") or {}
items = v.get("latest_item_info") or []
plays = [i.get("play") for i in items if isinstance(i.get("play"), int)]
sponsored = v.get("latest_star_item_info") or []
return {
"play_median": (desc.get("play_medium") or {}).get("rate"),
"interaction_rate": (desc.get("interaction") or {}).get("rate"),
"recent_items": len(items),
"recent_play_median": statistics.median(plays) if plays else None,
"sponsored_items": len(sponsored),
"sponsored_play_median": (desc.get("play_medium_enrollment") or {}).get("rate"),
}
In my runs the payload had three parts. data_description held summary metrics (play_medium, interaction, video_view_rate), each with a rate value and comparisons. The *_enrollment variants appeared to describe Xingtu-ordered videos. latest_item_info listed 15 recent videos with play, like, comment, share, duration, and item_date. latest_star_item_info listed recent sponsored videos in the same shape.
For Rishiji (run c6d7e6f1-8615-406c-a776-98266ecf1111), the play median was about 1.79 million and the sponsored-video median about 386,000. That ratio, organic vs. sponsored, is the number I’d show a brand first.
The gotcha: for Chinese National Geography (run c4a11532-3bcb-4fe9-a4f7-e448172bb999), every data_description entry was an empty object. The video lists were still there. That’s why the helper also computes recent_play_median from latest_item_info and reads each summary with .get(). Don’t let an empty summary turn into a zero in your comparison.
The kol-video-performance-v1 reference: required kolId and boolean onlyAssign. As with platformChannel, the description of the allowed values is cut off after “as follows:”.
Step 4: rate card and cost estimates
def pricing(kol_id: str) -> dict:
p = call("douyin/xingtu/kol-service-price-v1", {"kolId": kol_id, "platformChannel": "_1"})
card = {x.get("desc"): x.get("price") for x in p.get("price_info", [])
if x.get("enable") and x.get("task_category") == 1}
cp = call("douyin/xingtu/kol-cp-info-v1", {"kolId": kol_id})
return {
"rate_card": card,
"industry_tags": p.get("industry_tags", []),
"expect_vv": (cp.get("expect_vv") or {}).get("value"),
"expect_cpm_60s": (cp.get("expect_cpm") or {}).get("cpm_60"),
"expect_cpe_60s": (cp.get("expect_cpe") or {}).get("cpe_60"),
}
price_info is a list of service items. Each one has a desc label (for example 植入视频 for an integrated placement, 定制视频 for a custom video), a price, enable, settlement_desc, and a task_category. Some items in my runs were ad-distribution add-ons under a different task_category with token prices, and one was a “settle by natural plays” item with no price. Filtering on enable and task_category == 1 kept only the video formats.
For Rishiji (run 8bc1807b-f27a-4cd6-883a-1d66b8ac5b3d), a trimmed excerpt:
{
"industry_tags": ["食品饮料-水饮冲调", "3C及电器-大家电", "母婴宠物-宠物生活"],
"price_info": [
{"desc": "植入视频", "price": 180000, "settlement_desc": "固定价格", "task_category": 1, "enable": true},
{"desc": "定制视频", "price": 300000, "settlement_desc": "固定价格", "task_category": 1, "enable": true}
]
}
kol-cp-info-v1 (run be7df1c1-692c-427d-9aa9-7ccbd7d26028) returned expect_vv, plus expect_cpm and expect_cpe keyed 21_60 and 60. The units aren’t documented, so I checked them. Dividing the integrated-placement price by expect_vv and multiplying by 100,000 reproduced cpm_21_60 for all three accounts. So the CPM looked like fen (1/100 yuan) per 1,000 expected views, with the integrated placement as the 21–60 second tier. The 60-second figure matched the custom-video price for two of three accounts but not Rishiji. Treat both as inferences from my data, and compute your own CPM from price and expected views if it drives a decision.
The kol-service-price-v1 reference: POST /v1/api/douyin/xingtu/kol-service-price-v1 with required kolId and platformChannel. The description also contains an enterprise-account note and a per-call price line; the SandBase catalog currently lists this route as Free.
Putting it together: compare the shortlist
def evaluate(keywords: list[str]) -> list[dict]:
rows = []
for kw in keywords:
try:
kol = resolve_kol(kw)
except (LookupError, RuntimeError) as e:
print("skip:", kw, e)
continue
kid = kol["kol_id"]
rows.append({**kol, **base_info(kid), "fans": fans_portrait(kid),
**video_performance(kid), **pricing(kid)})
return rows
rows = evaluate(["日食记", "中国国家地理", "一条"])
I ran this verbatim for three public media brands. That took 21 completed calls, plus the one retried call. Rounded results:
| Account | Followers | Recent play median | Integrated-video price | Expected views | Fans skew |
|---|---|---|---|---|---|
| Rishiji (food) | ~12.5M | ~4.1M (15 videos) | ~¥180k | ~3.1M | 24–30, emerging white-collar |
| Chinese National Geography (nature/science) | ~5.6M | ~0.78M | ~¥8k | ~0.29M | 31–40, emerging white-collar |
| Yitiao (lifestyle/culture) | ~6.7M | ~0.30M | ~¥153k | ~0.31M | 31–40, emerging white-collar |
The “recent play median” column is the median of the 15 listed videos, which I computed for all three accounts. It differs from the play_medium summary (about 1.79M for Rishiji) because Xingtu’s summary covers a window and method it doesn’t describe in the payload. Use one of them consistently across candidates.
The raw numbers suggest one reading. Chinese National Geography had the lowest cost per expected view by far. Rishiji delivered the most reach. Yitiao cost about as much as Rishiji for roughly a tenth of the expected views. But the brief decides. A home-furnishing brand might still pick Yitiao for its content themes (home renovation, home living), and each account’s industry_tags listed a different mix of industries. Hand the rows to a model with the brief and ask for a recommendation that cites these fields, not just the cheapest CPM.
Optional: follower trend and the xingtu-v2 family
kol-daily-fans-v1 takes kolId, startDate, and endDate (yyyy-MM-dd). For Rishiji over 2026-09-01 to 2026-09-28 (run f67341bd-80a0-4387-90a5-928d54f669e3), it returned 28 daily fans_cnt points, which declined slowly from about 12.59M to 12.55M. A flat or shrinking follower base is worth knowing before you pay for reach.
The douyin/xingtu-v2 family covers the same ground with snake_case parameters. author-base-info, author-fans-distribution, author-spread-info, and author-cp-info all accepted the same kolId as o_author_id and completed in my tests (runs 8cd17f09-cadc-487c-b34d-8a4cf3245497, 82bcca0d-4e4b-4b8f-9ca8-75ca62aabdad, 59c050f8-3b65-4117-a30d-169c2a6136fa, 194b74e9-78f2-4711-9d4e-19bd00b07e51). author-cp-info returned the same values as kol-cp-info-v1. Pick one family and stay with it.
Documented vs. observed
| Item | Status |
|---|---|
POST /v1/api/douyin/xingtu/xingtu-kolid-by-sec-user-id with sec_user_id | Documented in the reference |
kol-base-info-v1 and kol-service-price-v1 with kolId + platformChannel | Documented (allowed channel values not listed) |
kol-video-performance-v1 with kolId + onlyAssign | Documented |
Envelope id / status / model / outputs[0].data | Documented |
platformChannel: "_1" for Douyin short video | Observed only |
base_resp.status_code, resolver id as kolId | Observed only |
distributions[].type_display / description / distribution_list | Observed only |
data_description, latest_item_info, latest_star_item_info | Observed only (summaries empty for one account) |
price_info[].desc / price / task_category; expect_cpm units | Observed only; units inferred |
Common use cases
Brand-deal shortlisting
Input: three to ten candidate nicknames. Output: one comparison row each. Endpoints: the full chain above.
Audience fit checks
Match a product’s target buyer against the fans portrait’s age, city tier, and segment. Compare it with the audience portrait to see who actually watches. Endpoints: kol-fans-portrait-v1, kol-audience-portrait-v1.
Sponsored-content decay
Compare the sponsored play median with the organic one to see how much reach a paid video loses. Endpoint: kol-video-performance-v1.
Pre-renewal review
Before renewing a creator, pull the daily follower trend and the current rate card. Endpoints: kol-daily-fans-v1, kol-service-price-v1.
Practical notes
- Resolve once, cache the
kolId. It stayed stable across every call I made for the same account. - Match nicknames exactly. Search returns look-alike accounts. For Xinshixiang, the Douyin search and Xingtu search also reported different follower counts, so pick one source for followers.
- Read summaries defensively.
data_descriptioncan be empty. Fall back to the raw video list. - Prefer raw counts to descriptions when a split matters, as the gender example shows.
- Keep contact data out. MCN introductions can include business contacts. Don’t store or display them.
- Retry transient upstream errors. One call in my run needed a second attempt.
- Public listing data only. This doesn’t place orders or read private campaign results.
FAQ
Do I need a Xingtu advertiser account?
No. You authenticate to SandBase with SANDBASE_API_KEY. Placing an order still requires Xingtu itself.
Is it free? The Douyin endpoints used here, Xingtu routes included, are currently listed as Free in the SandBase catalog. The Xingtu reference descriptions also contain a per-call price line, so check the catalog for the current status before production use.
What if a creator isn’t on Xingtu?
The resolver returns no id, and resolve_kol raises LookupError. evaluate then skips that creator.
Which platformChannel should I send?
"_1" worked for Douyin short video in my tests, and "1" returned the same data. The reference doesn’t list the values yet.
Are the prices final? They’re the listed rate card at call time. Actual deals can differ, so treat them as a starting point.
Wrap up
A Douyin nickname is enough to build a commercial profile of a creator. Resolve the Xingtu kolId, then read the base info, fans portrait, recent performance, and rate card, and you have rows an agent can compare against a brief. In my test of three media brands, the cheapest reach and the largest reach were different accounts, which is exactly the decision this data is for. For more Douyin endpoints, see the Douyin public data API hub.