A sound that is about to take off does not look different from a sound that already peaked - not if you only see one number. Both show a large video count. The difference is entirely in the shape of the curve, and TikTok does not hand you the curve. This guide covers building your own: how to seed a watchlist, how to snapshot counts on a schedule with music-info, how to compute velocity rather than volume, and how to confirm a spike with music-posts and post-detail.
The honest starting point: you seed the watchlist
There is no "sounds trending right now" call in this API, or in any unofficial TikTok API worth trusting. Every music endpoint is a lookup: hand it a music ID, get that track back. Nothing enumerates the platform for you.
That constraint shapes the whole design. You cannot ask what is trending; you can only ask what a specific sound looks like right now, repeatedly, and compare. So the first job is assembling a candidate list, and the second is keeping it cheap enough to re-measure often.
Where music IDs come from
Every feed item in the catalogue carries a music block, and that block carries an id. Four practical harvesters:
- Creators you treat as bellwethers. Pull their uploads and read
music.idoff each item. Accounts that are early to sounds are a better filter than any ranking you could build. - Hashtags in your niche.
challenge-postsreturns items with the samemusicblock, so a tag feed doubles as a sound feed. - Individual videos someone flags.
post-detailexposesitemInfo.itemStruct.music.idfor any post ID. - Locations, if your interest is geographic rather than topical -
place-postsitems carry the same block.
import requests
HEADERS = {"X-PrimeAPI-Key": "YOUR_API_KEY"}
def sound_ids_from_creator(sec_uid, count=35):
r = requests.get(
"https://api.primeapi.co/user-posts",
params={"secUid": sec_uid, "count": count, "cursor": "0"},
headers=HEADERS, timeout=20,
)
items = r.json().get("data", {}).get("itemList", [])
found = {}
for it in items:
m = it.get("music") or {}
if m.get("id"):
found[m["id"]] = m.get("title")
return found
Getting a secUid for a handle is a one-off lookup with userinfo-by-username; the identifier itself is explained in what a TikTok secUid is and how to get one, and one page of 35 items is enough here - you are collecting sound IDs, not mirroring a catalogue. Run this across twenty bellwether accounts once a week and you will keep a watchlist of a few hundred sounds without thinking about it.
The number the tracker is built on
One field does most of the work. music-info takes a single required parameter, musicId, and returns musicInfo.stats.videoCount - how many videos currently use that sound.
curl -s "https://api.primeapi.co/music-info?musicId=7224128604890990593" \
-H "X-PrimeAPI-Key: YOUR_API_KEY"
| Field | Holds |
|---|---|
musicInfo.stats.videoCount | Videos using the sound. The one number you snapshot. |
musicInfo.music.id, title, authorName, album | Track identity, for labelling rows in your own interface. |
musicInfo.music.duration, shoot_duration | Full track length and the segment length offered in the editor. |
musicInfo.music.original, isCopyrighted, is_commerce_music, is_unlimited_music, private | Flags TikTok sets on the track. Worth storing if you care whether a sound is a creator original or a licensed release. |
musicInfo.music.playUrl, coverLarge / coverMedium / coverThumb | Audio address and cover art at three sizes. |
musicInfo.artist, artists | The account the sound is attributed to: id, uniqueId, nickname, secUid, signature, avatars. |
extra.now | Server-side timestamp for the response. Useful as a sanity check against your own clock. |
shareMeta.title, desc | The link-preview text TikTok generates for the sound page. |
The field-by-field walkthrough of this response lives in the sound and music info guide. For tracking purposes, only videoCount changes meaningfully between calls - everything else is metadata you can store once.
Snapshot on a schedule
A snapshot job is short: loop the watchlist, call music-info once per sound, write one row per sound per run. Nothing is cached anywhere in the chain, so each value is what the platform reports at that instant - which is exactly what a time series needs, and also why you have to run the job yourself rather than asking for yesterday's figure.
import time, requests
HEADERS = {"X-PrimeAPI-Key": "YOUR_API_KEY"}
def snapshot(music_id):
r = requests.get(
"https://api.primeapi.co/music-info",
params={"musicId": music_id},
headers=HEADERS, timeout=15,
)
info = r.json().get("musicInfo")
if not info:
return None # unavailable or removed sound
return {
"music_id": info["music"]["id"],
"title": info["music"]["title"],
"author_name": info["music"]["authorName"],
"video_count": info["stats"]["videoCount"],
"captured_at": int(time.time()),
"balance": r.headers.get("X-PrimeAPI-Balance"),
}
def run(watchlist, db):
for mid in watchlist:
row = snapshot(mid)
if row:
db.execute(
"INSERT OR IGNORE INTO sound_snapshot VALUES (?,?,?)",
(row["music_id"], row["captured_at"], row["video_count"]),
)
time.sleep(0.7) # stay under 100 requests per minute
db.commit()
Two details matter more than they look. Sleep between calls: the default limit is 100 requests per minute, and a tight loop over 400 sounds will cross it. And record captured_at from your own clock at write time rather than assuming the job ran on schedule - a cron run that starts nine minutes late will quietly distort every rate you compute from it if you use the nominal time instead.
Velocity, not volume
Sorting a watchlist by videoCount produces a list of last year's hits. Catalogue tracks accumulate millions of uses and then sit there, adding a fraction of a percent a week. A sound in breakout does the opposite: a modest base, growing fast relative to itself.
So compute three things from consecutive snapshots and rank on those instead:
- Absolute velocity - new videos per hour. Catches large sounds still moving.
- Relative velocity - velocity divided by the existing base. Catches small sounds moving fast, which is the earlier signal.
- Acceleration - the change in velocity between the last two intervals. A rising velocity means the curve has not turned over yet; a falling one usually means you are late.
// rows: [{ capturedAt, videoCount }, ...] oldest first
function velocity(rows) {
if (rows.length < 2) return null;
const a = rows[rows.length - 2];
const b = rows[rows.length - 1];
const hours = (b.capturedAt - a.capturedAt) / 3600;
if (hours <= 0) return null;
const perHour = (b.videoCount - a.videoCount) / hours;
const base = Math.max(a.videoCount, 500); // floor tiny denominators
return { perHour, relative: perHour / base };
}
function acceleration(rows) {
const now = velocity(rows);
const prev = velocity(rows.slice(0, -1));
if (!now || !prev) return null;
return now.perHour - prev.perHour; // positive = still picking up
}
Floor the denominator. A sound going from 3 videos to 12 is a 300 percent gain and almost always noise, and without a floor those rows will dominate every ranking you build. Handle negative deltas too: videoCount falls when videos are deleted or accounts go private, so a negative velocity is a normal reading, not a bug to clamp away.
A second signal from the feed
Video count alone cannot tell you whether new uses are getting watched. music-posts can. It takes musicId plus optional count and cursor, and returns the clips built on that track.
Its shape differs from the other feeds - the payload is code, msg, processed_time and data, with clips in data.videos under snake_case names, plus data.cursor and an integer data.hasMore.
Field in data.videos[] | Holds |
|---|---|
aweme_id, video_id | Post identifiers. Feed aweme_id to post-detail. |
create_time | Upload time as a Unix timestamp. The freshness signal. |
play_count, digg_count, comment_count, share_count, collect_count, download_count | Per-video engagement counters. |
title, content_desc | Caption text for the clip. |
duration, size, wm_size | Clip length and file sizes. |
play, wmplay, cover, origin_cover, ai_dynamic_cover | Playable addresses and thumbnails. |
region, is_ad, mentioned_users | Publishing region, ad flag, tagged accounts. |
import time, statistics, requests
def freshness(music_id, count=30):
r = requests.get(
"https://api.primeapi.co/music-posts",
params={"musicId": music_id, "count": count, "cursor": 0},
headers=HEADERS, timeout=20,
)
data = r.json().get("data", {})
vids = data.get("videos", [])
if not vids:
return None
cutoff = int(time.time()) - 86400
return {
"sampled": len(vids),
"last_24h": sum(1 for v in vids if v["create_time"] >= cutoff),
"median_plays": statistics.median(v["play_count"] for v in vids),
"has_more": bool(data.get("hasMore")),
}
Two derived numbers are worth keeping. The share of the sampled page posted in the last 24 hours tells you the sound is being picked up right now rather than having been picked up last month. The median play_count of recent uses tells you whether those pickups are landing - a sound gaining videos that nobody watches is a different phenomenon from one gaining videos that each pull six figures. Run this only on the top of your velocity ranking; a full sweep of the watchlist is not worth the credits. The paging mechanics are covered in finding every video using a TikTok sound.
Confirming a spike with post-detail
When a sound jumps, one video is usually driving it. Take the highest play_count entry from the feed sample, pass its aweme_id as postId, and post-detail returns the complete record under itemInfo.itemStruct: stats and statsV2 with playCount, diggCount, commentCount, shareCount, collectCount and repostCount, the author block with authorStats, the challenges array, and createTime. That is what turns "the number moved" into an explanation you can put in a report. The full breakdown is in the video data guide.
Storing the series
Keep two tables. Metadata changes almost never, so fetch it once. Counts change constantly, so append and never update.
CREATE TABLE sound (
music_id TEXT PRIMARY KEY,
title TEXT,
author_name TEXT,
duration INTEGER,
is_commerce INTEGER,
first_seen INTEGER
);
CREATE TABLE sound_snapshot (
music_id TEXT NOT NULL,
captured_at INTEGER NOT NULL,
video_count INTEGER NOT NULL,
PRIMARY KEY (music_id, captured_at)
);
Store raw counts only. Velocity, relative growth and acceleration are all recomputable from the snapshots, and you will change the formulas - the floor value, the window length, the smoothing - more often than you expect. If you had written derived values into the table, every change would mean a backfill you cannot do, because the underlying counts are gone the moment you did not record them.
What it costs to run
Every call is 1 credit, so the arithmetic is simple: sounds multiplied by snapshots per day. A 300-sound watchlist checked four times daily is 1,200 credits a day, about 36,000 a month, which fits inside the Pro tier at $59.90 for 50,000 credits. Hourly checks on a 100-sound shortlist come to roughly 72,000 a month and want the Ultra tier. Feed sampling and detail confirmations add on top, but only for the handful of sounds that actually moved. Current tiers are on the pricing page, and a new account starts with 50 free credits - enough to snapshot a ten-sound watchlist five times and see whether the shape of your data is what you expected.
Watch X-PrimeAPI-Balance on every response and alert when it drops below one full sweep. A tracker that silently stops writing rows leaves a gap you can never fill in. The per-minute limit is 100 requests by default; the four error messages, the header format and the rest of the reference are in the API documentation, and you can try any of these calls against your own key first in the playground.
Limits worth accepting up front
No discovery: the watchlist is yours to build and yours to maintain, and a sound you never added is a sound you will never see rise. No backfill: your history starts when your cron job does. Counts can fall as well as rise. And a sound sampled once an hour will still miss the first few hours of a breakout that began overnight - which is an argument for a wide, cheap daily sweep feeding a narrow, frequent one, rather than trying to run everything at high frequency.
What you get in exchange is a series nobody else has, built from three calls and a scheduler. Start collecting before you know exactly what you want to measure, because the measuring can be redesigned later and the data cannot. PrimeApi is an independent service and is not affiliated with or endorsed by TikTok or ByteDance.
Frequently asked questions
Is there a TikTok trending sounds endpoint?
No, and no honest provider can give you one. PrimeApi exposes lookups keyed by a music ID: music-info returns the track record and how many videos use it, music-posts lists those videos. Both need an ID you already hold. A trend tracker is therefore built the other way round - you assemble a watchlist of candidate sounds yourself, then measure each one repeatedly and let the movement rank them.
Which field tells me how many videos use a sound?
musicInfo.stats.videoCount in the music-info response. It is a single integer and it is the backbone of the whole tracker: one call per sound per snapshot, and the difference between consecutive snapshots is the raw growth figure everything else is derived from.
Why measure velocity instead of total video count?
Because absolute totals rank catalogue tracks that have been on the platform for years above the sound that gained forty thousand videos this week. A three-million-video evergreen adds a rounding error per day; a breakout adds a large share of its own base per hour. Sorting by change per hour, and by change relative to the existing base, surfaces the second kind.
How often should I snapshot each sound?
It depends on what you are trying to catch. Hourly snapshots on a short shortlist detect breakouts inside the same day; daily snapshots on a few hundred sounds are enough for weekly reporting and cost far less. Each snapshot is 1 credit per sound, so 300 sounds checked four times a day is 1,200 credits a day - roughly 36,000 a month.
Can I backfill historical data for a sound?
No. The API is real time with no caching, so every call returns the value at the moment you ask and there is no archive to query. Your time series starts on the day you start collecting it, which is the main argument for seeding a wide watchlist early and trimming it later rather than the reverse.
Does music-posts return the same structure as the other feed endpoints?
No, and this catches people out. Most feeds return itemList with camelCase fields, while music-posts wraps its payload as code, msg, processed_time and data, with the clips in data.videos using snake_case names such as play_count, digg_count and create_time. Its hasMore is an integer rather than a boolean too, so write a separate parser for it.