Developers · API reference

TikTok Stats API

Quality tiers, engagement, media URLs and profile data — no key required. Last verified: October 10, 2026.

The same data that powers the stats tool is available over HTTP. Everything below answers an unauthenticated request — no account, no token. If you need a higher or contractual quota, contact us rather than assuming the public limit is the ceiling.

Quickstart

Base URL

https://v2.voidaep.dev

curl

curl "https://v2.voidaep.dev/api/proxy/tiktok/analyze?url=https%3A%2F%2Fvt.tiktok.com%2FZSbg6Fu1t%2F"

javascript

const url = encodeURIComponent("https://vt.tiktok.com/ZSbg6Fu1t/");
const res  = await fetch(`https://v2.voidaep.dev/api/proxy/tiktok/analyze?url=${url}`);
const data = await res.json();

// HTTP 200 does not mean success — check the body.
if (data.id === null) throw new Error("video not found");
console.log(data.tiers, data.vqScore, data.categories);

Access & limits

No authentication

Every endpoint on this page answers a plain request. No key, no session, no account.

60 requests / minute / IP

Applied across /api. Excess requests return 429.

Cache on your side

A single video's data does not change meaningfully within a minute. The upstreams we read are rate-limited.

Browser use is restricted by a CORS allowlist, so call the API from your backend. Server-to-server requests are unaffected.

Endpoints

Every response below is shown in full — no fields trimmed. The only edits are the long signed CDN URLs, abbreviated with … so the blocks stay readable. Values are a real capture from October 10, 2026.

GET/api/proxy/tiktok/analyze

Analyze a video

Quality tiers, delivery profiles, classification and engagement for one video. This is the endpoint behind the stats card.

Parameters

url required — a TikTok video URL, short (vt./vm.), canonical, or with a query string.
probe optional — probe=0 skips the media probe that completes the frame rate. Faster (no ~1 MB range fetch), but browserHD.fps stays 0. Defaults to on.
debug optional — debug=1 appends fetch diagnostics to the response.

Response — complete, all 28 fields

{
  "browserHD": { "width": 1080, "height": 1300, "fps": 60 },
  "phoneSD":   { "width": 576,  "height": 694,  "fps": 30 },
  "tiers": [
    {
      "gearName": "play_addr",
      "fps": 60,
      "codec": "h264",
      "bitrateMbps": 11.608,
      "width": 1080,
      "height": 1300,
      "dataSizeMb": 20.76
    },
    {
      "gearName": "lowest_540_1",
      "fps": 30,
      "codec": "hevc",
      "bitrateMbps": 0.254,
      "width": 576,
      "height": 694,
      "dataSizeMb": 0.47
    }
  ],
  "createType": null,
  "id": "7693321115183090951",
  "region": "NG",
  "vqScore": 57.7,
  "duration": 15,
  "topWidth": 1080,
  "topHeight": 1300,
  "topFps": 60,
  "fpsProbed": true,
  "health": {
    "score": 99,
    "grade": "Excellent",
    "summary": "Clean upload — nothing is blocking delivery.",
    "issues": [
      "H.264-only stream — HEVC uploads preserve ~2× the detail at the same bitrate"
    ]
  },
  "count": 2,
  "tips": [
    "aftereffects",
    "ae",
    "edit",
    "rex4u_",
    "nigeriatiktok🇳🇬🇳🇬🇳🇬",
    "teddys_tt"
  ],
  "hashtags": [
    "aftereffects",
    "ae",
    "edit",
    "rex4u_",
    "nigeriatiktok🇳🇬🇳🇬🇳🇬"
  ],
  "mentions": [
    "teddys_tt"
  ],
  "categories": [
    "Random Shoot",
    "Others"
  ],
  "isPhotoPost": false,
  "photoCount": null,
  "isProhibited": false,
  "statusMsg": null,
  "viewCount": 2407,
  "likeCount": 540,
  "commentCount": 42,
  "shareCount": 20,
  "collectCount": 39,
  "createTime": 1791240918
}

Failure

{ "id": null, "count": 0, "tiers": [], "browserHD": null, … }   // still HTTP 200

An unresolvable URL returns 200 with nulls. Treat id === null or count === 0 as not-found.

tiers — every rendition found. gearName is TikTok's internal label: play_addr is the source stream a browser receives, lowest_*/adapt_* are the mobile ladder. Sorted ascending by resolution. The source tier is enriched in place by the probe: its fps and codec come from the media itself, so play_addr.fps is 60 above rather than null.

browserHD / phoneSD — what a browser and a phone are actually served, derived from the tiers.

They can be the same rendition. TikTok's payload does not always expose both gear families. Many uploads carry a single gear — a lone normal_540_0, for example — which is browser-delivered, so there is no adapt_* mobile ladder to label Phone and phoneSD falls back to the browser tier. Both rows then describe the same file and the two objects are identical. That is a single-gear upload, not a second stream:

Single-gear upload — identical profiles

{
  "browserHD": { "width": 576, "height": 640, "fps": 30 },
  "phoneSD":   { "width": 576, "height": 640, "fps": 30 },
  "tiers": [
    { "gearName": "play_addr", "fps": 30, "codec": "h264",
      "bitrateMbps": 1.388, "width": 576, "height": 640, "dataSizeMb": 3.06 }
  ],
  "count": 1
}

Compare the two profiles' dimensions before treating them as two separate measurements.

fps can be 0 on either profile — the page payload carries no frame rate for the source stream, and 0 is also what phoneSD.fps reads when the parser fell back to the source rendition. Read the true value off the media with /inspect, or let the built-in probe fill it in. A successful probe fills every profile whose dimensions match the source, and sets fpsProbed: true; if the media cannot be reached the frame rate stays 0 and fpsProbed stays false. 0 always means "unknown", never "measured as zero".

The probe reads the media through a third-party mirror (tikwm), which mirrors a video lazily. On a video it has not mirrored yet the first attempt can miss; the server then asks tikwm for the real media path and retries. A 0 is therefore a transient upstream miss, not a property of the video.

isProhibited — TikTok's restriction flag, the shadowban signal. statusMsg carries the reason when present.

fpsProbed — true when the frame rate was read off the media itself by a byte-range probe, rather than taken from the page. The page never carries fps for the source stream, so this field is how you tell "probed and true" from "unknown, reported as 0". Pass probe=0 to skip it: the call gets faster, the source tier's fps stays null and browserHD.fps stays 0.

health — the same 0–99 verdict the stats card displays, with a grade and up to three plain-language issues. Inputs: resolution, codec, frame rate, bitrate (size × 8 ÷ duration), VQ score, the restriction flag and whether the video carries hashtags. It is computed server-side so every consumer sees the same number for the same video instead of each reimplementing the scoring.

duration — seconds, and the input the bitrate above depends on.

vqScore is null when TikTok has not scored the video. It arrives as a string, and upstream uses "0" as its placeholder for "not scored" — a genuine score is never 0 (this page's own capture reads 57.7). The API normalises "0" to null so the health verdict does not dock 10 points and report a weak encoder on an unscored video. Whenever the field is a number it is a real 0–100 score.

GET/api/proxy/tiktok/video

Video media + metadata

Media URLs plus metadata. This is the endpoint that yields downloadable media.

Parameters

url required — a TikTok video URL.
hd optional — hd=1 requests the HD variant (the default behaviour).

Response — complete, all 30 fields

{
  "code": 0,
  "msg": "success",
  "processed_time": 0.7767,
  "data": {
    "id": "7693321115183090951",
    "region": "NG",
    "title": "Editing my Crush from Secondary School vc : @teddys_tt…",
    "content_desc": [
      "Editing my Crush from Secondary School vc : @teddys_tt",
      "",
      "#aftereffects #ae #edit #rex4u_ #nigeriatiktok🇳🇬🇳🇬🇳🇬"
    ],
    "cover": "https://p16-common-sign.tiktokcdn-eu.com/tos-alisg-p-0…",
    "duration": 15,
    "play":   "/video/media/play/7693321115183090951.mp4",
    "wmplay": "/video/media/wmplay/7693321115183090951.mp4",
    "hdplay": "/video/media/hdplay/7693321115183090951.mp4",
    "size": 21764489,
    "wm_size": 21764489,
    "hd_size": 21764489,
    "music": "/video/music/7693321115183090951.mp3",
    "music_info": {
      "id": "7693321178355256084",
      "title": "original sound - rex4u_",
      "play": "https://v16-ies-music.tiktokcdn-us.com/b0cb496205eff5b…",
      "author": "Rex",
      "original": true,
      "duration": 15,
      "album": "",
      "create_time": 1791240925
    },
    "play_count": 2407,
    "digg_count": 540,
    "comment_count": 42,
    "share_count": 20,
    "download_count": 8,
    "collect_count": 39,
    "create_time": 1791240918,
    "anchors": null,
    "anchors_extras": "",
    "is_ad": false,
    "commerce_info": {
      "adv_promotable": false,
      "auction_ad_invited": false,
      "bc_label_test_text": "Promotional content",
      "brand_organic_type": 2001,
      "branded_content_type": 0,
      "is_diversion_ad": 0,
      "organic_log_extra": "{\"req_id\":\"20261010073923C4C9BC8C26E043245AD5\"}",
      "with_comment_filter_words": false
    },
    "commercial_video_info": "",
    "item_comment_settings": 0,
    "mentioned_users": "7185638812226733062",
    "author": {
      "id": "7538094272013648904",
      "unique_id": "rex4u_",
      "nickname": "Rex",
      "avatar": "https://p16-common-sign.tiktokcdn-eu.com/tos-alisg-avt…",
      "region": "NG"
    },
    "is_nff_or_nr": false
  }
}

Failure

{ "code": -1, "msg": "Url parsing is failed! Please check url.", "processed_time": 2.6 }   // HTTP 200

Check code === 0 && data.

Media paths are RELATIVE. Prefix them with https://www.tikwm.com before use — e.g. https://www.tikwm.com/video/media/hdplay/<id>.mp4.

author.avatar is absolute — a *.tiktokcdn-*.com URL. It is signed and expires, so fetch it fresh rather than storing it.

Audio: the music field points at a path the upstream refuses with 403. Use music_info.play, the absolute CDN audio URL.

Cached 10 minutes server-side; a repeat request inside that window will not reflect changed counters.

The video box and its four download links

Both come out of the /video response shown above — there is no separate endpoint for either. How you style them is your call; this is the data contract, so a rebuild produces the same set rather than one generic link.

The video at the top

It plays the same media the download links point at, resolved by first match:

player source   play  →  hdplay  →  wmplay     // first non-empty wins
poster          cover

Both go through our proxy — media via /api/proxy/stream?url=<media>, poster via /api/proxy/image?url=<cover>. Because /stream answers 302 for open hosts, the bytes are served by the CDN and never relay through our server.

The player and the download links intentionally share the same URLs. If a client can play it, it can download it, and the two can never disagree about which rendition they refer to.

The four download links

Four distinct renditions. Each renders only when its field is non-empty, so a partial response legitimately yields fewer than four — that is the usual reason a rebuilt UI shows only one.

LabelFieldSub-label
HD Quality data.hdplay HEVC · <hd_size> when the HD stream is HEVC, otherwise <hd_size>
Standard MP4 data.play H.264 · <size>
With Watermark data.wmplay <wm_size>
Audio Only data.music_info.play (fallback: data.music) the track title, else MP3

Sizes are bytes — size, hd_size, wm_size. Format them however you like; only the raw number is contractual.

wmplay is byte-identical to the SD stream. TikTok's web payload carries no separate watermarked rendition, so the third link is not a distinct file — either label it as the same stream, or leave the third link out rather than implying you are offering something you are not.

Audio: prefer music_info.play. The music field is a path form the upstream refuses with 403.

All four point at our proxy (/api/proxy/stream), never at a raw CDN URL. That is what keeps a download working when the CDN URL is signed or has expired, and what lets the 302 offload the bytes — and it is why a rebuilt UI should not link the relative paths directly.

GET/api/proxy/tiktok/inspect

Inspect a media file

Reads real dimensions, frame rate and codec off the file itself by range-fetching its MP4 header. Use it to complete or corroborate /analyze.

Parameters

url required — a media URL (an http(s) MP4), not a TikTok page URL.

Response

{ "width": 1080, "height": 1300, "fps": 60, "codec": "h264" }

Page URLs always fail. Passing https://vt.tiktok.com/… returns {"error":"could not determine dimensions"} — the response is HTML, so there is no MP4 header to parse. Feed it the hdplay or play value from /video.

Audio-only URLs return the same error — there is no video track to measure. That is correct behaviour, not a bug. A distinct message, stream unreachable, means the media host refused the request outright.

GET/api/proxy/tiktok/user/profile

User profile

44 fields per account, including derived ratios. Read from the profile page our server fetches directly, so it does not depend on a third party.

Parameters

username required — handle with or without the @, e.g. _void.aep_.

Response — complete, all 44 fields

{
  "user": {
    "id": "7366252783652357126",
    "uniqueId": "_void.aep_",
    "nickname": "￴ ￴ ￴ ￴￴",
    "signature": "proud 🇰🇪\n120 fps method pc n mobile 👇",
    "avatarLarger": "https://p16-common-sign.tiktokcdn-eu.com/tos-alisg-a…",
    "verified": false,
    "privateAccount": false,
    "createTime": 1720759380,
    "region": null,
    "followerCount": 3281,
    "followingCount": 500,
    "heartCount": 121200,
    "videoCount": 19,
    "friendCount": 493,
    "diggCount": 0,
    "secUid": "MS4wLjABAAAAfDWNbTAtvtucVwfRiFSoSd_scjedS0NajkZ9-r8v…",
    "avatarMedium": "https://p16-common-sign.tiktokcdn-eu.com/tos-alisg-a…",
    "avatarThumb": "https://p16-common-sign.tiktokcdn-eu.com/tos-alisg-a…",
    "shortId": null,
    "language": "en",
    "roomId": null,
    "bioLink": "iamvoid.site",
    "ttSeller": false,
    "commerceUserInfo": {
      "commerceUser": false
    },
    "isOrganization": false,
    "isEmbedBanned": false,
    "secret": false,
    "ftc": false,
    "openFavorite": false,
    "followingVisibility": 2,
    "profileTab": {
      "showMusicTab": false,
      "showQuestionTab": false,
      "showPlayListTab": false
    },
    "commentSetting": 0,
    "duetSetting": 0,
    "stitchSetting": 0,
    "downloadSetting": 3,
    "UserStoryStatus": 0,
    "uniqueIdModifyTime": null,
    "nickNameModifyTime": 1779648036,
    "storyStatus": 0,
    "commerceUser": false,
    "likesPerVideo": 6379,
    "followerFollowingRatio": 6.56,
    "accountAgeDays": 820,
    "handleAgeDays": null
  },
  "recentPosts": [],
  "postsUnavailable": true
}

Derived fields. likesPerVideo = round(heartCount ÷ videoCount). followerFollowingRatio = followers ÷ following, 2dp. accountAgeDays from createTime. handleAgeDays is null when the handle has never been changed (upstream reports 0).

bioLink is flattened — upstream sends { link, risk }; this returns just the URL string, or null.

A nickname can render invisible — some accounts set a display name of U+FFF4 characters. That is genuinely what TikTok stores; the API reproduces it faithfully rather than hiding it.

region is null — not present upstream at all. language is the closest field available.

Posts are not available. The profile page payload contains no post list, so recentPosts is empty with postsUnavailable: true. secUid is the key you would need to fetch posts from another source.

GET/api/proxy/image

Image proxy

Server-side image fetch, for covers and avatars.

Parameters

url required — an image URL on an allowlisted host.

Response

200  image/jpeg                          // allowed host
400  {"error":"Unsupported image host."}  // host not allowlisted

An allowlist applies. tikwm.com and the major tiktokcdn families are permitted — https://p16-common-sign.tiktokcdn-eu.com/… returns 200 image/jpeg. A bare path such as /video/avatar/<id>.jpeg yields 400, because there is no host to check.

GET/api/proxy/stream

Media stream / download

Answers with a 302 to the upstream CDN for hosts that serve publicly, so media bytes never pass through our server. Hosts that require session cookies are relayed instead.

Parameters

url required — the media URL to fetch (a tikwm.com or TikTok CDN host).

Response

302  location: https://www.tikwm.com/…   // open host — the CDN serves it
200  (relayed bytes)                     // cookie-bound host, e.g. *.tiktok.com
403  {"error":"host not allowed"}         // not allowlisted

Range requests are supported — a redirect chain ends at 206 video/mp4 with accept-ranges: bytes, so seeking works in a player.

Prime-CDN caveat: v16/v19-webapp-prime.tiktok.com refuses our server's IP outright — no cookie, referer or UA combination gets through. That is why media URLs are sourced from a third party.

GET/api/stats · /api/health · /api/patcher/status

Counters & status

Lifetime counters and service state, no parameters. Useful as an integration health check.

Response

GET /api/stats           { "totalPatched": 25040, "totalStreamed": 555 }
GET /api/health          { "ok": true, "uptime": 123649.74 }
GET /api/patcher/status  { "ok": true, "available": true, "maintenance": false, "message": null }

Counters are lifetime totals and increase continuously.

Conventions

Three envelopes are in use, and two of them signal failure inside a 200 response. Always look at the body.

EnvelopeUsed byFailure looks like
flat, all-null/analyzecount: 0 or id: null
{ code, msg, data }/videocode: -1
{ error }/inspect, /image, /streamthe request was rejected

Encode every query value. Video ids are 19 digits, e.g. 7693321115183090951. Do not probe /api/internal/* or /api/debug/* — they are traps that always 404 and flag your IP.

Changelog

Open the stats tool Back to Void Studio