TikTokAnswer

TikTok API get user videos: any public account, with two limits

TikTok's API lists videos only for users who log in to your app. Ours lists any public account; geofenced videos cost extra, huge accounts end early.

9 min read

Short answer

  • TikTok's Display API lists a user's public videos, 20 per page, but only with that user's own access token: they have to log in to your app first. The Research API can filter by username but is for approved researchers, in 30-day windows; its FAQ says commercial users are not eligible (developers.tiktok.com, read 2026-10-17).
  • ToolzerHub's /v2/tiktok/app/user/posts lists any public account's videos by username at 2 credits a page. Videos TikTok marks as geofenced are left out of the list, but their IDs are named, and a batch route fetches them 25 at a time for 38 credits a call, about 15 times the per-video cost of listed ones.
  • On 2026-10-17 that gave NatGeo 1,538 of the 1,539 videos its profile shows; only 307 came from the list itself. On the NBA's account of about 24,400 videos the list ended at about 2,750 to 2,950 videos, and we found no way past it.
On this page

TikTok's official API lists a user's videos only when that user has logged in to your app and granted the video.list scope. For any other public account, ToolzerHub's /v2/tiktok/app/user/posts lists the videos by username, with play, like, comment, share and save counts. The list skips videos TikTok marks person_geo_fencing; their IDs are still named, so the script below fetches them separately. Fetching them costs more, and very large accounts end early. We read TikTok's documentation and ran ours on 2026-10-17.

Can the TikTok API get a user's videos?

Only the videos of users who authorize you. TikTok's Display API reference (last updated August 4, 2026): "The /v2/video/list/ endpoint can return a paginated list for the given user's public TikTok video posts, sorted by create_time in descending order." Its Authorization header is "The token that bears the authorization of the TikTok user, which is obtained through /oauth/access_token/." A page holds "Default is 10. Maximum is 20."

Route Whose videos Who can use it
Display API Users who log in Any app a user authorizes
Research API Any public account Approved researchers
ToolzerHub Any public account Anyone with a key

The Research API's video query can filter by username, with up to 100 videos a request, but "The end_date must be no more than 30 days after the start_date". Its FAQ answers "I am a creator, advertiser, or commercial user. Am I eligible for access to the Research Tools?" with "No." Our Research API post covers who can apply.

List any public account's videos

We make ToolzerHub. Our TikTok API lists a public account's videos by username, with one API key and no TikTok app. GET /v2/tiktok/app/user/posts takes unique_id and max_cursor (0, then the previous response's max_cursor) and costs 2 credits a call (our OpenAPI file, 2026-10-17). A page held up to 20 videos, often fewer.

Each page also carries lite_filter_reasons: the IDs of videos left out of that page, mostly with the reason person_geo_fencing. POST /v2/tiktok/app/video/multi takes up to 25 of those IDs and returns the videos, for 38 credits a call. An unknown username returns HTTP 200 with no list.

This script writes each account's videos to CSV. Pass yes as the third argument to fetch the geofenced ones.

python
import csv
import json
import os
import sys
import time
from datetime import datetime, timezone

import requests

BASE = "https://api.toolzerhub.com/v2/tiktok/app"
HEADERS = {"x-api-key": os.environ.get("TOOLZERHUB_API_KEY", "")}
FIELDS = ["account", "video_id", "url", "listed", "posted_at", "pinned", "seconds",
          "plays", "likes", "comments", "shares", "saves", "caption"]
calls = 0


def cell(value):
    # Spreadsheets run text starting with = + - or @ as a formula; an apostrophe keeps it text.
    if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r"):
        return "'" + value
    return value


def call(path, params=None, ids=None, tries=4):
    # 429 and 503 are retried; the wait comes from the error body or the Retry-After header.
    global calls
    for attempt in range(tries):
        if ids:
            r = requests.post(BASE + path, headers=HEADERS, json=ids, timeout=90)
        else:
            r = requests.get(BASE + path, headers=HEADERS, params=params, timeout=90)
        calls += 1
        if r.status_code == 400:  # malformed input: skip this account
            print("bad request:", str(r.json()["error"].get("message")).split("\n")[0])
            return None
        if r.status_code not in (429, 503) or attempt == tries - 1:
            if r.status_code >= 500:  # any 5xx, or a 503 past the retries: skip
                print(f"HTTP {r.status_code}, skipping:", r.text[:120].replace("\n", " "))
                return None
            r.raise_for_status()
            return r.json().get("data")
        try:
            wait = r.json()["error"]["details"]["retryAfter"]
        except (ValueError, KeyError, TypeError):
            wait = None
        try:
            wait = int(wait or r.headers.get("Retry-After") or 5 * (attempt + 1))
        except ValueError:
            wait = 5 * (attempt + 1)
        time.sleep(min(wait, 60))


def write(writer, account, v, listed):
    author, stats = v.get("author") or {}, v.get("statistics") or {}
    writer.writerow({
        "account": account, "video_id": v["aweme_id"], "listed": listed,
        "url": f"https://www.tiktok.com/@{author.get('unique_id') or account}/video/{v['aweme_id']}",
        "posted_at": datetime.fromtimestamp(v["create_time"], timezone.utc).isoformat()
        if v.get("create_time") else "",
        "pinned": "yes" if v.get("is_top") else "",  # pinned videos come first, out of date order
        "seconds": round(((v.get("video") or {}).get("duration") or 0) / 1000) or "",
        "plays": stats.get("play_count"), "likes": stats.get("digg_count"),
        "comments": stats.get("comment_count"), "shares": stats.get("share_count"),
        "saves": stats.get("collect_count"),
        "caption": cell((v.get("desc") or "").replace("\r", " ").replace("\n", " ")),
    })


def export_account(writer, account, max_pages, fetch_hidden):
    params, seen, hidden, pages = {"unique_id": account, "max_cursor": 0}, set(), set(), 0
    status = "page cap reached, more available"
    while pages < max_pages:
        data = call("/user/posts", params)
        videos = (data or {}).get("aweme_list") or []
        if not videos and not (data or {}).get("has_more"):  # unknown users: 200 with no list
            status = "stopped early: failed or empty page" if pages else "nothing returned"
            break
        pages += 1
        for v in videos:  # a page can be empty when every video on it is geofenced
            if v.get("aweme_id") and v["aweme_id"] not in seen:  # pages can repeat a video
                seen.add(v["aweme_id"])
                write(writer, account, v, "yes")
        # Videos TikTok won't show in this region are left out, but their IDs are listed here.
        reasons = data.get("lite_filter_reasons") or {}
        hidden.update((json.loads(reasons) if isinstance(reasons, str) else reasons) or {})
        if not (data.get("has_more") and data.get("max_cursor")):
            status = "end"
            break
        params["max_cursor"] = data["max_cursor"]
        time.sleep(0.3)
    hidden -= seen
    got = 0
    if fetch_hidden:
        todo = sorted(hidden)
        for i in range(0, len(todo), 25):  # the batch route takes up to 25 IDs a call
            for v in call("/video/multi", ids=todo[i:i + 25]) or []:
                if v.get("aweme_id") and v["aweme_id"] not in seen:
                    seen.add(v["aweme_id"])
                    write(writer, account, v, "no, fetched by ID")
                    got += 1
    print(f"{account}: {len(seen) - got} listed, {len(hidden)} hidden, {got} fetched, {pages} pages ({status})")
    return len(seen)


def main(accounts, max_pages="50", fetch_hidden="no", out="tiktok_videos.csv"):
    total = 0
    with open(out, "w", newline="", encoding="utf-8-sig") as f:  # BOM: Excel reads emoji
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        for account in [a.strip().lstrip("@") for a in accounts.split(",") if a.strip()]:
            total += export_account(writer, account, int(max_pages), fetch_hidden == "yes")
    print(f"wrote {total} videos to {out} in {calls} calls")


if __name__ == "__main__":
    if len(sys.argv) < 2 or (len(sys.argv) > 3 and sys.argv[3] not in ("yes", "no")):
        sys.exit("usage: python tiktok_user_videos.py nasa,nba [max_pages] [yes|no fetch hidden] [file.csv]")
    if not HEADERS["x-api-key"]:
        sys.exit("set TOOLZERHUB_API_KEY first")
    main(*sys.argv[1:])

A 400 skips that account. A 429 or 503 is retried with the wait from retryAfter or Retry-After; a 503 that keeps coming back, or any other 5xx, skips the account, and a 429 that outlasts the retries, a 401 or a 402 stops the run. Pinned videos come first, out of date order, and are marked. The listed column says which rows came from the list and which were fetched by ID. Captions that start with =, +, - or @ get a leading apostrophe, which shows in some CSV viewers. For Instagram, see listing a public account's reels. We tested those branches against a stand-in server, not live.

Run it

bash
export TOOLZERHUB_API_KEY=your_key
python tiktok_user_videos.py "nasa, @natgeo ,,zzzznotauser9q8z7x" 70 yes tiktok_videos.csv

Output on 2026-10-17, with a stray space, an @, an empty item and an unknown username added:

text
nasa: 57 listed, 0 hidden, 0 fetched, 3 pages (end)
natgeo: 307 listed, 1232 hidden, 1231 fetched, 34 pages (end)
zzzznotauser9q8z7x: 0 listed, 0 hidden, 0 fetched, 0 pages (nothing returned)
wrote 1595 videos to tiktok_videos.csv in 88 calls

It took 211 seconds. NatGeo's profile shows 1,539 videos: the list held 307 and named 1,232 more, and the batch route returned 1,231 of those. The fetched videos lean recent: 400 of them are from 2025, against 19 listed from that year.

What we measured

All runs on 2026-10-17, paged until has_more was 0. "Profile" is the video count from our /v1/tiktok/profile route the same day; the NBA's is rounded.

Account Profile Listed Named, left out Pages
NASA 57 57 0 3
Duolingo 1,204 1,205 not counted 61
NatGeo 1,539 307 1,232 34
NBA 24,400 2,754 1 139
  • NatGeo's listed and named videos add up to its profile count exactly. Of the 1,232 named IDs, 1,229 gave person_geo_fencing as the reason.
  • The NBA's list is not geofenced: one ID was left out. It ended at 2,937 videos in one run and 2,754 in another, with has_more 0, out of 24,400. max_cursor takes a millisecond timestamp, but the same timestamp returned different pages minutes apart, so we found no reliable way to reach the rest.
  • Pages repeat a few videos (18 repeats over 148 NBA pages); the script keeps one row per ID.
  • Reliability: 464 calls in the 7 days to 2026-10-17, 463 successful and none a server error, but nearly all were our own test runs, so this says little about load.

What it costs

List calls are 2 credits a page, at $0.99 to $1.90 per 1,000 credits ($1.45 on the $29 plan). Duolingo's 1,205 videos took 61 calls, 122 credits: 12 to 23 cents. NASA's 57 took 6 credits. Fetching geofenced videos costs more: NatGeo's 34 list calls and 50 batch calls came to 1,968 credits, $1.95 to $3.74, for 1,538 videos. The free 100 credits a month cover about 50 list pages. Failed calls cost nothing.

Where TikTok's API is the better pick

Use the Display API for videos of users who log in to your app: it is TikTok's own route, with the user's consent built in. Use the Research API if you qualify as a researcher; it filters by date, region and keyword, with 1,000 requests a day. Our list misses videos TikTok marks as geofenced unless you pay to fetch them by ID, and on very large accounts like the NBA it stops long before the end. For just a creator's latest 11 videos, a free Python script reads them from TikTok's embed page with no key; how to scrape TikTok with Python runs it.

We are not affiliated with TikTok or ByteDance.

Sources

Get this data from your own code

The TikTok API returns it as JSON with one x-api-key header. 100 free credits monthly.