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.
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
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:
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_fencingas 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_more0, out of 24,400.max_cursortakes 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
- TikTok for Developers, List Videos (Display API), https://developers.tiktok.com/doc/tiktok-api-v2-video-list/, last updated 2026-08-04, read 2026-10-17
- TikTok for Developers, Display API overview, https://developers.tiktok.com/doc/display-api-overview/, last updated 2026-08-04, read 2026-10-17
- TikTok for Developers, Research API query videos, https://developers.tiktok.com/doc/research-api-specs-query-videos/, last updated 2026-09-01, read 2026-10-17
- TikTok for Developers, Research API FAQ, https://developers.tiktok.com/doc/research-api-faq/, last updated 2026-09-01, read 2026-10-17
- ToolzerHub OpenAPI file, https://api.toolzerhub.com/openapi.json, checked 2026-10-17
- ToolzerHub pricing, https://toolzerhub.com/pricing, checked 2026-10-17