To detect viral TikTok trends programmatically, you pull a hashtag's recent videos through an API, read each video's play_count and create_time, and rank them by a velocity score (plays divided by hours since posting) so the fastest movers float to the top before they peak. That's the whole trick: trends aren't about which video has the most views right now, they're about which video is gaining views fastest. A clip with 80,000 plays four hours old is hotter than one sitting at 2 million after three weeks. This tutorial builds that pipeline with three Scavio TikTok endpoints: /hashtag returns a challenge's id and aggregate stats, /hashtag/videos returns recent uploads with per-video metrics, and /search/videos cross-checks breakout clips by keyword. Each call costs 1 credit ($0.005), uses Bearer auth, and returns structured JSON with no proxies or CAPTCHA to manage. You'll compute velocity in a few lines, page through results correctly (numeric cursor for hashtag and search feeds), and end with a ranked list of rising clips you can poll on a schedule. The same API key also hits Google, Amazon, and YouTube, so a sound trending on TikTok can become a product-search query in the next call.
Prerequisites
- A Scavio API key (free signup includes 50 one-time credits)
- Python 3.9+ with the requests library, or Node 18+ for the JS version
- Your key exported as SCAVIO_API_KEY in the environment
- A hashtag or keyword you want to watch (for example a niche your product sits in)
Walkthrough
Step 1: Resolve the hashtag to a challenge id and baseline stats
Call /hashtag with the bare hashtag (no # symbol) to get its challenge_id plus aggregate view and video counts. Those aggregate numbers are your baseline: a hashtag that gained millions of videos this week is already crowded, while a smaller one climbing fast is where you find early movers. Hold onto the challenge_id, it identifies the same challenge across requests.
import os, requests
H = {"Authorization": f"Bearer {os.environ['SCAVIO_API_KEY']}", "Content-Type": "application/json"}
r = requests.post("https://api.scavio.dev/api/v1/tiktok/hashtag", headers=H,
json={"hashtag": "quietluxury"})
tag = r.json()
print(tag["challenge_id"], tag.get("view_count"), tag.get("video_count"))Step 2: Pull recent videos for that hashtag with pagination
Call /hashtag/videos with the hashtag and a count. The response gives you recent uploads, each with play_count, digg_count (likes), and create_time (a Unix timestamp). To go deeper, pass the numeric cursor returned by the previous page back into the next request. This feed is the raw material for the velocity ranking. Do not use max_cursor here, that field belongs to /user/posts only.
videos = []
cursor = 0
for _ in range(3): # three pages
resp = requests.post("https://api.scavio.dev/api/v1/tiktok/hashtag/videos",
headers=H, json={"hashtag": "quietluxury", "count": 30, "cursor": cursor}).json()
videos.extend(resp["videos"])
cursor = resp.get("cursor", 0)
if not resp.get("has_more"):
break
print(len(videos), "videos collected")Step 3: Compute a velocity score and rank the movers
Velocity is play_count divided by the number of hours since the video was posted. Convert create_time to hours-ago with the current Unix time, guard against division by zero for brand-new clips, then sort descending. The top of this list is what's accelerating right now, not what already won. Tune the formula later (weight digg_count, decay older clips) but plays-per-hour is a strong, cheap first signal.
import time
now = time.time()
def velocity(v):
age_hours = max((now - v["create_time"]) / 3600, 0.5)
return v["play_count"] / age_hours
ranked = sorted(videos, key=velocity, reverse=True)
for v in ranked[:5]:
print(round(velocity(v)), "plays/hr", v["video_id"], v["play_count"])Step 4: Cross-check breakout clips by keyword
A hashtag feed can miss videos that go viral under a caption or sound rather than the tag. Call /search/videos with a keyword (the sound name, a phrase, a product) and the same numeric cursor pattern to catch those. Merge the two video lists, dedupe on video_id, and re-rank. Run the whole pipeline on a schedule (every few hours) and diff the ranked lists to see what's newly climbing.
search = requests.post("https://api.scavio.dev/api/v1/tiktok/search/videos",
headers=H, json={"query": "quiet luxury outfit", "count": 30, "cursor": 0}).json()
seen = {v["video_id"] for v in videos}
for v in search["videos"]:
if v["video_id"] not in seen:
videos.append(v)
seen.add(v["video_id"])
print(len(videos), "unique videos after cross-check")Python Example
import os, time, requests
H = {"Authorization": f"Bearer {os.environ['SCAVIO_API_KEY']}", "Content-Type": "application/json"}
BASE = "https://api.scavio.dev/api/v1/tiktok"
def hashtag_videos(hashtag, pages=3, count=30):
out, cursor = [], 0
for _ in range(pages):
resp = requests.post(f"{BASE}/hashtag/videos", headers=H,
json={"hashtag": hashtag, "count": count, "cursor": cursor}).json()
out.extend(resp.get("videos", []))
cursor = resp.get("cursor", 0)
if not resp.get("has_more"):
break
return out
def search_videos(query, count=30):
resp = requests.post(f"{BASE}/search/videos", headers=H,
json={"query": query, "count": count, "cursor": 0}).json()
return resp.get("videos", [])
def velocity(v, now):
age_hours = max((now - v["create_time"]) / 3600, 0.5)
return v["play_count"] / age_hours
def detect_trends(hashtag, keyword):
tag = requests.post(f"{BASE}/hashtag", headers=H, json={"hashtag": hashtag}).json()
print("challenge", tag["challenge_id"], "views", tag.get("view_count"))
videos = hashtag_videos(hashtag)
seen = {v["video_id"] for v in videos}
for v in search_videos(keyword):
if v["video_id"] not in seen:
videos.append(v); seen.add(v["video_id"])
now = time.time()
ranked = sorted(videos, key=lambda v: velocity(v, now), reverse=True)
for v in ranked[:5]:
print(round(velocity(v, now)), "plays/hr", v["video_id"], v["play_count"], v["digg_count"])
return ranked
if __name__ == "__main__":
detect_trends("quietluxury", "quiet luxury outfit")JavaScript Example
const H = {
Authorization: `Bearer ${process.env.SCAVIO_API_KEY}`,
"Content-Type": "application/json",
};
const BASE = "https://api.scavio.dev/api/v1/tiktok";
async function post(path, body) {
const r = await fetch(`${BASE}${path}`, {
method: "POST", headers: H, body: JSON.stringify(body),
});
return r.json();
}
async function hashtagVideos(hashtag, pages = 3, count = 30) {
const out = [];
let cursor = 0;
for (let i = 0; i < pages; i++) {
const resp = await post("/hashtag/videos", { hashtag, count, cursor });
out.push(...(resp.videos || []));
cursor = resp.cursor || 0;
if (!resp.has_more) break;
}
return out;
}
function velocity(v, now) {
const ageHours = Math.max((now - v.create_time) / 3600, 0.5);
return v.play_count / ageHours;
}
async function detectTrends(hashtag, keyword) {
const tag = await post("/hashtag", { hashtag });
console.log("challenge", tag.challenge_id, "views", tag.view_count);
const videos = await hashtagVideos(hashtag);
const seen = new Set(videos.map((v) => v.video_id));
const search = await post("/search/videos", { query: keyword, count: 30, cursor: 0 });
for (const v of search.videos || []) {
if (!seen.has(v.video_id)) { videos.push(v); seen.add(v.video_id); }
}
const now = Date.now() / 1000;
const ranked = videos.sort((a, b) => velocity(b, now) - velocity(a, now));
for (const v of ranked.slice(0, 5)) {
console.log(Math.round(velocity(v, now)), "plays/hr", v.video_id, v.play_count, v.digg_count);
}
return ranked;
}
detectTrends("quietluxury", "quiet luxury outfit");Expected Output
challenge 7012XXXXXXXXXXXXX views 4XX,XXX,XXX
45 videos collected
52 unique videos after cross-check
Top movers by velocity (plays per hour):
~9,800 plays/hr video_id 73210... play_count 88,000 digg_count 9,400 (posted ~9h ago)
~6,100 plays/hr video_id 73208... play_count 73,000 digg_count 7,100 (posted ~12h ago)
~3,400 plays/hr video_id 73199... play_count 61,000 digg_count 5,200 (posted ~18h ago)
~1,900 plays/hr video_id 73185... play_count 410,000 digg_count 38,000 (posted ~9d ago)
~1,200 plays/hr video_id 73176... play_count 27,000 digg_count 2,300 (posted ~22h ago)
Note: the 88k-play clip outranks the 410k-play clip because it's accelerating far faster. Numbers above are illustrative placeholders, not real metrics.