Blog/Developer Tools/

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.

Dark cinematic render of three glass creator profile cards linked to an audience chart, a rising bar chart, and a price tag, converging on a comparison scale

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 Douyin sec_user_id. Resolve it first with douyin/xingtu/xingtu-kolid-by-sec-user-id.
  • kol-base-info-v1, kol-fans-portrait-v1, and kol-video-performance-v1 give the creator’s categories, follower makeup, and recent organic vs. sponsored plays.
  • kol-service-price-v1 returns the Xingtu rate card, and kol-cp-info-v1 returns 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 needUse
Shortlist and compare creators with public Xingtu dataSandBase douyin/xingtu endpoints
Place an order, sign a contract, or brief a creatorXingtu itself (official advertiser account)
Private campaign results or account-only dataNeither 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

  1. Find the account with douyin/search/user-search-v2 (keyword, cursor) and keep its sec_user_id.
  2. Resolve the Xingtu kolId with douyin/xingtu/xingtu-kolid-by-sec-user-id (sec_user_id).
  3. Read base info with douyin/xingtu/kol-base-info-v1 (kolId, platformChannel).
  4. Read the fans portrait with douyin/xingtu/kol-fans-portrait-v1 (kolId).
  5. Read recent video performance with douyin/xingtu/kol-video-performance-v1 (kolId, onlyAssign).
  6. Read the rate card and cost estimates with douyin/xingtu/kol-service-price-v1 (kolId, platformChannel) and douyin/xingtu/kol-cp-info-v1 (kolId).

SandBase Douyin API catalog page showing the Douyin endpoint list with Available and Free status on the selected endpoint 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.

SandBase API reference for the Douyin Xingtu kolid by sec_user_id endpoint 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.

SandBase API reference for the Douyin Xingtu kol-video-performance-v1 endpoint 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.

SandBase API reference for the Douyin Xingtu kol-service-price-v1 endpoint 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:

AccountFollowersRecent play medianIntegrated-video priceExpected viewsFans skew
Rishiji (food)~12.5M~4.1M (15 videos)~¥180k~3.1M24–30, emerging white-collar
Chinese National Geography (nature/science)~5.6M~0.78M~¥8k~0.29M31–40, emerging white-collar
Yitiao (lifestyle/culture)~6.7M~0.30M~¥153k~0.31M31–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

ItemStatus
POST /v1/api/douyin/xingtu/xingtu-kolid-by-sec-user-id with sec_user_idDocumented in the reference
kol-base-info-v1 and kol-service-price-v1 with kolId + platformChannelDocumented (allowed channel values not listed)
kol-video-performance-v1 with kolId + onlyAssignDocumented
Envelope id / status / model / outputs[0].dataDocumented
platformChannel: "_1" for Douyin short videoObserved only
base_resp.status_code, resolver id as kolIdObserved only
distributions[].type_display / description / distribution_listObserved only
data_description, latest_item_info, latest_star_item_infoObserved only (summaries empty for one account)
price_info[].desc / price / task_category; expect_cpm unitsObserved 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.

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_description can 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.