Build a TikTok Scraper in Python (2026)

By Anurag Pathak · · 11 min read

A TikTok data client in Python is about a hundred lines. The HTTP call is not the hard part; reading the list shape is. A TikTok response is a list of lists, and any of them can be incomplete without a transport failure. This guide builds the client that reads the flags instead of trusting a clean array.

The code targets the documented TikTok API contract. The endpoints are in prelaunch, so I validated the response handling against a mocked HTTP layer and lint-checked the module (see the honesty note below) rather than faking a live call.

TL;DR: one API key, five endpoints. Every list returns items[], result_count, total_count (nullable), has_more, next_cursor, truncated, restricted and complete. When the response is short it adds a partialResults block. Read those and you will never silently build a dataset with gaps.

Set up the client

mkdir tiktok-client && cd tiktok-client
python3 -m venv .venv && source .venv/bin/activate
pip install requests python-dotenv
touch .env tiktok.py
SERPENT_API_KEY=sk_live_your_actual_key_here

Profile

Auth is the X-API-Key header. A profile call by username, with the enrichment you want turned on explicitly:

import os
import requests

API_KEY = os.environ["SERPENT_API_KEY"]
BASE = "https://apiserpent.com"
HEADERS = {"X-API-Key": API_KEY, "Accept": "application/json"}


def get(path, params):
    resp = requests.get(BASE + path, params=params, headers=HEADERS, timeout=20)
    resp.raise_for_status()
    return resp.json()


def profile(username, country="us", limit=30, **include):
    params = {"username": username, "country": country, "limit": limit}
    params.update(include)  # e.g. include_comments=1, include_media=1
    return get("/api/tiktok/profile", params)


data = profile("serpent.api")
print(data["author"]["username"], "followers", data["author"].get("follower_count"))
print("items", len(data.get("items", [])))

Written 25 Aug 2026 against the versioned contract; syntax-checked with python3 -m py_compile.

Video

By video_id or a url. Set include_media=1 to pull the media, and include_comments, include_related for the rest:

def video(video_id, country="us", include_comments=0, include_related=0, include_media=0):
    params = {
        "video_id": video_id,
        "country": country,
        "include_comments": include_comments,
        "include_related": include_related,
        "include_media": include_media,
    }
    return get("/api/tiktok/video", params)


v = video("7625229188693232918", include_media=1)
author = v["author"]
print(author["username"], v.get("music", {}).get("title"))
print("hashtags", [h.get("name") for h in v.get("hashtags", [])])
print("is_ad", v.get("is_ad"), "duet", v.get("duet_enabled"), "stitch", v.get("stitch_enabled"))

The author field is the true author, and media is only present when include_media=1, which is why that tier costs more.

def hashtag(tag, country="us", limit=30):
    return get("/api/tiktok/hashtag", {"tag": tag, "country": country, "limit": limit})


def search(q, country="us", limit=30):
    return get("/api/tiktok/search", {"q": q, "country": country, "limit": limit})


def music(music_id, country="us", limit=30):
    return get("/api/tiktok/music", {"music_id": music_id, "country": country, "limit": limit})
A dark computer screen with lines of code, the way you run the TikTok Python client

Read the list shape

This is the part most tutorials skip. A TikTok list is not just an array. Read every flag:

def inspect_list(payload):
    shape = {
        "result_count": payload.get("result_count"),
        "total_count": payload.get("total_count"),  # null when TikTok did not state one
        "has_more": payload.get("has_more"),
        "next_cursor": payload.get("next_cursor"),
        "truncated": payload.get("truncated"),
        "restricted": payload.get("restricted"),
        "complete": payload.get("complete"),
    }
    partial = payload.get("partialResults")
    if partial:
        shape["partial"] = {
            "requested": partial.get("requested"),
            "returned": partial.get("returned"),
            "reason": partial.get("reason"),  # only when short by >40%
            "note": partial.get("note"),
        }
    return shape


data = hashtag("python", limit=30)
print(inspect_list(data))

A response can be valid and still be complete: false, or carry a partialResults block with a reason. Treating those as a failure would be wrong; treating them as success would poison your dataset. The honest move is to record the flags with the rows.

Handle partial and deep runs

The synchronous cap is 180 items. Deeper runs go through the async form, which returns a job you poll rather than silently chopping the list. If a call is short by more than 40%, the response includes a reason so you know whether to back off, retry a smaller page, or switch to the async route.

def safe_search(q, country="us"):
    payload = search(q, country=country, limit=30)
    partial = payload.get("partialResults")
    if partial:
        returned = partial.get("returned", 0)
        requested = partial.get("requested", 0)
        if requested and returned and returned / requested < 0.6:
            print("short run:", partial.get("reason"))
    return payload

That is the whole point: a dataset built from partialResults you never looked at is a dataset you cannot trust. The flags turn a silent under-delivery into a number you can see.

A live crowd at a concert with warm stage light, representing the music and sound data a TikTok response can include

Why not self-host?

The same maintenance argument applies here as everywhere: a TikTok client you own is a contract you keep updating. The response shape drifts, the enrichments you need are not free to compute, and the cost of keeping the data honest is real.

At $0.50 per 1,000 for Basic, $3.00 per 1,000 for Enriched and $10.00 per 1,000 for Media, you are paying for typed JSON and for the flags you would otherwise build yourself. If you need a huge historical archive or an unusual field no API exposes, self-hosting is a genuinely reasonable fork. If you need a reliable feed today, the API is cheaper than the debugging time.

FAQ

Do I need a browser to run this?

No. It is a plain HTTP call with an API key. The only dependency is requests.

How do I get the media?

Set include_media=1. Media is never included by default, and it is the reason a Media call costs more than Basic.

What is the synchronous item cap?

Up to 180 items per synchronous call. Deeper runs go through the async form instead of being silently truncated.

What does partialResults mean?

It is a top-level block with requested and returned counts, plus a reason when the shortfall is large. It means the response is valid but not as complete as you asked for.

Is the author always the true creator?

On Serpent the author field is the true author of the video, not a re-post handle. Enriched responses include it consistently, along with music, hashtags, mentions, labels, is_ad, duet_enabled and stitch_enabled.

Honesty note on the code above: this module targets the documented contract and is lint-checked. The endpoints are in prelaunch, so the list-envelope and partialResults handling was exercised with a mocked HTTP layer (a complete list, a partial restricted list, and a media-flagged video) rather than a live run, and it passed. No latency or accuracy figure is claimed.

Related Posts