TikTok's data model does not have hashtags. It has challenges. The tag you type as #yourcampaign in a caption is stored as a challenge record with its own numeric ID, and that ID - not the text you typed - is what you pass to pull the video feed behind a tag. This guide covers the challenge-posts endpoint: where a challengeId actually comes from, how count and cursor walk a tag, what each feed item holds, and how to pair it with post-detail so a campaign tracker still makes sense on its tenth run.
Challenge or hashtag - the same thing
The naming is historical. Hashtags arrived on TikTok wrapped in branded "hashtag challenges", and the internal object kept the name even after tags became ordinary. So a challenge is a hashtag, a challengeId is a hashtag ID, and the challenges array you see hanging off a video is simply the list of tags in its caption. Once you read the two words as synonyms, the endpoint names stop looking strange.
What matters practically is that the identifier is numeric and opaque. There is no slug, no lowercase-and-strip rule you can apply to a tag name to derive it, and no way to guess it. It has to be read out of live data, which is the first job below.
Where a challengeId comes from
Every video item TikTok returns carries a challenges array listing the tags in its caption, and each entry in that array is a challenge record with its own ID and title. That makes the shortest reliable path to a tag ID a single lookup on any one video that already uses the tag.
For a campaign this is easy: you published a seed post, or your brief told creators which tag to use and you can see at least one of their videos. Take that video's URL, pull the numeric post ID off the end of it, and call post-detail.
curl -s "https://api.primeapi.co/post-detail?postId=7330315169584778539" \
-H "X-PrimeAPI-Key: YOUR_API_KEY"
The tags sit at itemInfo.itemStruct.challenges. A caption usually carries several, so match on the title rather than taking the first entry.
import requests
HEADERS = {"X-PrimeAPI-Key": "YOUR_API_KEY"}
def challenge_id_for(post_id, tag):
r = requests.get(
"https://api.primeapi.co/post-detail",
params={"postId": post_id},
headers=HEADERS, timeout=15,
)
item = r.json().get("itemInfo", {}).get("itemStruct")
if not item:
return None
for ch in item.get("challenges", []):
if ch.get("title", "").lower() == tag.lower().lstrip("#"):
return ch.get("id")
return None
print(challenge_id_for("7330315169584778539", "#fyp"))
Two things are worth doing here. First, print the whole challenges array once before you write matching logic - the interactive playground shows the raw payload for any endpoint against your own key, which beats guessing. Second, cache the result. A tag ID is stable, so this lookup is a one-off per campaign, not something to repeat on every sweep.
If you are still deciding which tag to build around, the phrasing side of the problem is separate: searched-suggest returns the queries TikTok itself proposes for a partial term, which is covered in the keyword research guide. Those come back as words, not IDs, so you still resolve the winner through a video as above.
Do not have a seed video? Pull a creator's feed with user-posts - resolve their handle to a secUid with userinfo-by-username first - and scan the items for the tag. Feed items expose the same challenges array as the detail call, so no extra request is needed once you have the page.
Calling challenge-posts
The base URL is https://api.primeapi.co/ and authentication is the single header X-PrimeAPI-Key. The endpoint takes one required parameter, challengeId, plus optional count and cursor.
curl
curl -s "https://api.primeapi.co/challenge-posts?challengeId=763263&count=5&cursor=0" \
-H "X-PrimeAPI-Key: YOUR_API_KEY"
Node.js (Axios)
const axios = require("axios");
async function tagPage(challengeId, cursor = "0", count = 30) {
const res = await axios.get("https://api.primeapi.co/challenge-posts", {
params: { challengeId, count, cursor },
headers: { "X-PrimeAPI-Key": "YOUR_API_KEY" }
});
console.log("Remaining credits:", res.headers["x-primeapi-balance"]);
return res.data;
}
tagPage("763263")
.then(d => {
(d.itemList || []).forEach(i =>
console.log(i.id, i.author.uniqueId, i.desc.slice(0, 60))
);
console.log("next cursor:", d.cursor, "hasMore:", d.hasMore);
})
.catch(err => console.error(err.response ? err.response.data : err.message));
Python (Requests)
import requests
HEADERS = {"X-PrimeAPI-Key": "YOUR_API_KEY"}
def sweep_tag(challenge_id, pages=5, count=30):
seen, cursor = {}, "0"
for _ in range(pages):
r = requests.get(
"https://api.primeapi.co/challenge-posts",
params={"challengeId": challenge_id, "count": count, "cursor": cursor},
headers=HEADERS, timeout=20,
)
data = r.json()
for item in data.get("itemList", []):
seen[item["id"]] = item
if not data.get("hasMore"):
break
cursor = data.get("cursor")
return list(seen.values())
videos = sweep_tag("763263")
print(len(videos), "videos")
What the response contains
The top level is flat: itemList with the videos, cursor as a string to send on the next call, a hasMore boolean, and the usual statusCode, status_code and status_msg fields alongside diagnostic extra and log_pb objects you can ignore.
| Field | Holds |
|---|---|
id | The post ID as a string. This is your primary key and the input to post-detail. |
desc | The caption exactly as published, hashtags and mentions included. |
createTime | Upload time as a Unix timestamp in seconds. |
author | The creator: id, uniqueId, nickname, secUid, signature, verified, privateAccount, avatars at three sizes, and interaction settings such as commentSetting, duetSetting, stitchSetting and downloadSetting. |
authorStats / authorStatsV2 | That creator's own totals: followerCount, followingCount, heartCount, heart, videoCount, diggCount, friendCount. |
music | The sound on the clip: id, title, authorName, playUrl, duration, shoot_duration, cover art at three sizes, and the original, isCopyrighted, is_commerce_music and is_unlimited_music flags. |
challenges | Every tag on this video, not only the one you queried - the raw material for co-occurrence analysis. |
contents, effectStickers | The caption content blocks and any effects used on the clip. |
isAd, isReviewing | Whether the post is promoted, and whether it is still under review. |
originalItem, officalItem, privateItem | Origin and visibility flags on the item itself. |
collected, digged, forFriend | Viewer-relative flags. They describe an anonymous session, so ignore them. |
duetEnabled, duetDisplay, itemCommentStatus, item_control | What other users are allowed to do with the post - duet, comment, repost. |
IsHDBitrate, CategoryType, diversificationId, AIGCDescription, ShowAIGC | Quality, classification and AI-generated-content markers TikTok attaches internally. |
Paging with count and cursor
Start at cursor=0, read cursor off each response and send it back on the next call. Treat it as an opaque token even though it currently looks like a running offset - echoing the returned value is the only pattern that stays correct if the format changes. hasMore tells you whether to continue, but on a large tag it will keep saying yes for a very long time, so put your own page cap in the loop. Deduplicating by item id, as the Python example does, matters more here than on a profile feed: tag feeds are ranked rather than strictly chronological, and the same video can surface on two pages.
Getting engagement counters
The feed item describes the video and its author well, but the per-video counters are a post-detail call away. Send the item's id and read stats and statsV2 - playCount, diggCount, commentCount, shareCount, collectCount, plus repostCount in the V2 block. The video data guide walks through that response in full, and normalising those counters against authorStats.followerCount is exactly the arithmetic in the engagement rate guide. Enrich selectively - the top 20 or 30 videos of a sweep - rather than every item, because each enrichment is its own credit.
Campaign tracking with a tag feed
A single sweep answers almost nothing. The same sweep repeated on a schedule answers most of what people want from hashtag data.
Participation volume over time
Bucket the items you collect by createTime and you get posts per day under the tag. Run the sweep daily, store rows keyed by post id with a first-seen timestamp, and the shape of adoption appears: the launch spike, the plateau, and whether a second wave followed a paid push. Because feeds are re-ranked between runs, the first-seen column is what makes the series trustworthy, not the page position.
Creator discovery and vetting
Each item hands you author.uniqueId, author.secUid, author.verified and the full authorStats block without a second request. That is enough to rank everyone posting under your tag by follower count, filter out private accounts, and pull a shortlist of creators already talking about your product unprompted. The secUid in hand also means you can jump straight to any of those creators' full feeds later.
Which tags travel with yours
Every item's own challenges array lists the other tags in that caption. Count them across a sweep and you have a co-occurrence table: the tags your audience pairs with yours, ranked by frequency. That is the cheapest tag research available, since it costs nothing beyond the sweep you were running anyway, and it usually beats guessing which adjacent tag to add to the next brief.
Sound attribution
The music.id on each item tells you what people are filming over. If one sound dominates your tag, that sound is doing part of the work - and it can be tracked on its own terms, as the guide to finding every video using a sound explains. A tag and its signature sound usually rise and fall together, and watching both is a better early warning than watching either alone.
Paid versus organic
The isAd flag separates promoted posts from organic ones. Filter them apart before reporting participation numbers, otherwise a media buy inflates what looks like unprompted adoption. Keep both series - the ratio between them is often the more interesting metric.
Credits, limits and errors
Every request costs 1 credit, including one that comes back empty, and the default rate limit is 100 requests per minute. Data is real-time with no caching, so counters reflect the moment you asked, and responses average around a second. Read your remaining balance from the X-PrimeAPI-Balance response header instead of polling a dashboard. New accounts get 50 free credits, and the paid tiers start at $9.90 for 2,500 credits and run to $279.00 for 500,000 - the full table is on the pricing page.
Four failures arrive as a JSON message and deserve explicit branches: "Please sign up to primeapi.co" means no key header was sent, "PrimeAPI-Key is not available" means the key is wrong or the account was never activated, the per-minute message means you crossed the rate limit, and "Your balance has been exhausted..." means you are out of credits. Separately, a valid request for a tag with nothing behind it returns an empty itemList and still costs a credit, so check the list length before indexing into it. The API documentation has the full error reference.
Putting it together
The workflow that holds up is four steps. Resolve the tag once through a seed video's challenges array and store the ID. Sweep challenge-posts on a schedule with a fixed page cap, deduplicating by post id and recording when each video was first seen. Enrich only the videos that matter with post-detail for counters. Then read the aggregates - posts per day, top creators, co-occurring tags, dominant sound - rather than staring at individual clips. Fifty free credits on a new account cover a full test of that loop before you commit to a plan. PrimeApi is an independent service and is not affiliated with or endorsed by TikTok or ByteDance.
Frequently asked questions
Why is the endpoint called challenge-posts and not hashtag-posts?
Because that is what TikTok calls the object internally. Hashtags started life on the platform as branded "hashtag challenges", and the data model kept the name: a tag in a caption is stored as a challenge record with its own numeric ID. Read "challenge" as "hashtag" everywhere in the response and the naming stops being confusing.
Can I pass a hashtag name instead of a challengeId?
No. challengeId is the numeric identifier, not the tag text, so #summer or summer will not resolve. You get the ID from the challenges array on any video that already carries the tag - call post-detail on one such post, find the entry whose title matches your tag, and reuse that ID from then on.
How many videos can I get per request?
You set the page size with the optional count parameter and walk the tag with cursor. Each response returns itemList plus a cursor value to send on the next call and a hasMore boolean telling you whether anything is left. Very large tags are effectively bottomless, so cap your own loop rather than waiting for hasMore to turn false.
Does the hashtag feed include play and like counts?
The feed items carry the caption, upload time, the full author block and authorStats, and the music object. For per-video engagement counters, take each item's id and call post-detail, which returns stats and statsV2 with playCount, diggCount, commentCount, shareCount and collectCount. That is one extra credit per video you decide to enrich.
How much does tracking one campaign hashtag cost?
One credit per request, whether it returns a full page or an empty one. A daily sweep that pulls five pages of a tag is 5 credits a day; enriching the 30 newest videos with post-detail adds 30 more. New accounts start with 50 free credits and every response returns your remaining balance in the X-PrimeAPI-Balance header.
Why do videos disappear from the feed between runs?
The data is fetched live with no caching, and TikTok reorders and filters tag feeds constantly - videos also get deleted, set to private or region-blocked after publication. Treat each sweep as a sample rather than a stable list: store items keyed by their id, keep a first-seen timestamp, and never assume page 3 today holds what page 3 held yesterday.