Deno is a great fit for TikTok scraping work. You get TypeScript out of the box with zero config, a permission model that forces you to be explicit about network and disk access, and a standard library built around the same fetch API that runs in browsers, Cloudflare Workers, Bun, and Deno Deploy. That means the code you write against the TikLiveAPI documentation on your laptop is the same code that will ship to an edge worker, with no bundler in the middle rewriting your imports.
This guide builds a small, idiomatic Deno + TypeScript client for api.tikliveapi.com. We will model responses as native interfaces, walk paginated endpoints with AsyncGenerator, add a retry helper with hand-rolled exponential backoff, run requests through a promise pool, deploy the whole thing as a Deno Deploy cron worker, and write results to CSV. If you have not signed up yet, grab a key from the register page and top up from pricing; credits never expire and one request equals one credit.
Three things matter when you are pulling structured social data at scale:
tsc step, no ts-node, no tsconfig.json debates. Deno reads .ts files directly and ships a fast V8 + Rust runtime.--allow-net=api.tikliveapi.com --allow-env=TIKLIVE_API_KEY --allow-write=./out.fetch, Response, AbortController, and Headers primitives you already know from the browser. No axios, no node-fetch, no polyfill drama.The TikLiveAPI documentation exposes 37 REST endpoints authenticated via a single X-Api-Key header. No OAuth, no TikTok password, no cookie jar. Perfect for an edge runtime.
deno --version). The standard library APIs used here are stable in 1.40+.Store the key as an environment variable. On macOS/Linux that means export TIKLIVE_API_KEY=... in your shell rc. On Deno Deploy it becomes a project secret (covered below).
Start with a thin client that bakes in the base URL, header name, and a typed get(). Deno.env.get returns string | undefined, so we narrow with a guard.
// client.ts
const BASE_URL = "https://api.tikliveapi.com";
const apiKey = Deno.env.get("TIKLIVE_API_KEY");
if (!apiKey) {
console.error("Missing TIKLIVE_API_KEY env var");
Deno.exit(1);
}
export async function tikGet<T>(
path: string,
params: Record<string, string | number | undefined> = {},
signal?: AbortSignal,
): Promise<T> {
const url = new URL(path, BASE_URL);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) url.searchParams.set(k, String(v));
}
const res = await fetch(url, {
headers: { "X-Api-Key": apiKey! },
signal,
});
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText} on ${path}`);
}
return await res.json() as T;
}
The users category is the natural starting point. Two shapes recur across the surface: a nested user{} + stats{} pair on profile lookups, and a flat snake_case list item on posts and follower endpoints. Model both explicitly so the compiler catches typos like followerCount vs followersCount.
// types.ts
export interface TikUser {
id: string;
uniqueId: string;
nickname: string;
avatarThumb: string;
avatarMedium: string;
avatarLarger: string;
signature: string;
verified: boolean;
secUid: string;
privateAccount: boolean;
bioLink?: { link: string; risk: number };
}
export interface TikStats {
followingCount: number;
followerCount: number;
heartCount: number;
videoCount: number;
diggCount: number;
heart: number;
}
export interface UserInfoResponse {
user: TikUser;
stats: TikStats;
}
export interface UserIdResponse {
id: string;
}
Posts come back flat and snake_case. The /post-detail/ endpoint exposes three watermark-free download URLs - play, wmplay, and hdplay - which is why TikLiveAPI is a popular pick for download tooling.
export interface PostItem {
aweme_id: string;
video_id: string;
region: string;
title: string;
cover: string;
play: string;
wmplay: string;
music: string;
music_info: { id: string; title: string; author: string; original: boolean };
play_count: number;
digg_count: number;
comment_count: number;
share_count: number;
download_count: number;
collect_count: number;
create_time: number;
author: { id: string; unique_id: string; nickname: string; avatar: string };
}
export interface UserPostsResponse {
videos: PostItem[];
cursor: string; // ms timestamp string
hasMore: boolean; // camelCase, not snake
}
Most workflows start from a handle and need the numeric userid. Hit /userid/ first, then /userinfo-by-username/, with a small type guard to keep strict mode happy.
// users.ts
import { tikGet } from "./client.ts";
import type { UserIdResponse, UserInfoResponse } from "./types.ts";
export async function resolveUserId(username: string): Promise<string> {
const r = await tikGet<UserIdResponse>("/userid/", { username });
if (typeof r.id !== "string" || r.id.length === 0) {
throw new Error(`No id returned for ${username}`);
}
return r.id;
}
export function getUserInfo(username: string) {
return tikGet<UserInfoResponse>("/userinfo-by-username/", { username });
}
The cleanest TypeScript idiom for cursor pagination is async function*. Callers for await the items and never see the cursor plumbing. Note that /user-posts/ uses cursor while /user-followers/ uses time and /user-following/ returns its list under followings (with the plural s).
// posts.ts
import { tikGet } from "./client.ts";
import type { PostItem, UserPostsResponse } from "./types.ts";
export async function* iterUserPosts(
userid: string,
pageSize = 30,
): AsyncGenerator<PostItem> {
let cursor: string | undefined;
while (true) {
const page = await tikGet<UserPostsResponse>("/user-posts/", {
userid,
count: pageSize,
cursor,
});
for (const v of page.videos) yield v;
if (!page.hasMore) break;
cursor = page.cursor;
}
}
Followers and following are nearly identical, but the cursor field is called time (unix seconds) and the response key differs:
// graph.ts
interface FollowerItem {
id: string; unique_id: string; nickname: string;
follower_count: number; following_count: number;
}
interface FollowersResp { followers: FollowerItem[]; total: number; time: number; hasMore: boolean }
interface FollowingResp { followings: FollowerItem[]; total: number; time: number; hasMore: boolean }
export async function* iterFollowers(userid: string) {
let time: number | undefined;
while (true) {
const p = await tikGet<FollowersResp>("/user-followers/", { userid, count: 50, time });
for (const f of p.followers) yield f;
if (!p.hasMore) break;
time = p.time;
}
}
export async function* iterFollowing(userid: string) {
let time: number | undefined;
while (true) {
const p = await tikGet<FollowingResp>("/user-following/", { userid, count: 50, time });
for (const f of p.followings) yield f; // followings, not following
if (!p.hasMore) break;
time = p.time;
}
}
TikLiveAPI's standard limit is 200 requests/minute. A short retry helper handles the occasional 429 or transient 5xx without pulling in a dependency.
// retry.ts
export async function withBackoff<T>(
fn: () => Promise<T>,
{ tries = 5, baseMs = 400 } = {},
): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < tries; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
const msg = err instanceof Error ? err.message : String(err);
const retriable = /\b(429|5\d\d)\b/.test(msg);
if (!retriable || i === tries - 1) throw err;
const delay = baseMs * 2 ** i + Math.random() * 200;
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastErr;
}
Wrap any call: await withBackoff(() => tikGet<...>(...)).
When you are fanning out across hundreds of usernames, unbounded Promise.all will trip the rate limit. A tiny pool keeps in-flight requests capped.
// pool.ts
export async function pool<T, R>(
items: T[],
size: number,
worker: (item: T) => Promise<R>,
): Promise<R[]> {
const out: R[] = new Array(items.length);
let idx = 0;
const runners = Array.from({ length: size }, async () => {
while (true) {
const i = idx++;
if (i >= items.length) return;
out[i] = await worker(items[i]);
}
});
await Promise.all(runners);
return out;
}
With a 200 req/min ceiling, 6-8 concurrent workers is a safe starting point. Test live in the playground before scaling up.
No streaming library needed for modest exports. Build the CSV in memory, then flush.
// csv.ts
function escape(v: unknown): string {
const s = String(v ?? "");
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
export async function writeCsv<T extends Record<string, unknown>>(
path: string,
rows: T[],
headers: (keyof T)[],
) {
const head = headers.join(",");
const body = rows.map((r) => headers.map((h) => escape(r[h])).join(",")).join("\n");
await Deno.writeTextFile(path, head + "\n" + body + "\n");
}
// main.ts
import { resolveUserId, getUserInfo } from "./users.ts";
import { iterUserPosts } from "./posts.ts";
import { withBackoff } from "./retry.ts";
import { pool } from "./pool.ts";
import { writeCsv } from "./csv.ts";
const handles = ["charlidamelio", "khaby.lame", "bellapoarch"];
const rows = await pool(handles, 4, async (handle) => {
const userid = await withBackoff(() => resolveUserId(handle));
const info = await withBackoff(() => getUserInfo(handle));
const posts: { aweme_id: string; play_count: number }[] = [];
for await (const p of iterUserPosts(userid, 30)) {
posts.push({ aweme_id: p.aweme_id, play_count: p.play_count });
if (posts.length >= 60) break; // cap per user
}
const totalPlays = posts.reduce((a, p) => a + p.play_count, 0);
return {
handle,
followers: info.stats.followerCount,
videos: info.stats.videoCount,
recent_plays: totalPlays,
};
});
await writeCsv("./out/creators.csv", rows, ["handle", "followers", "videos", "recent_plays"]);
console.log("Wrote", rows.length, "rows");
Run it:
deno run \
--allow-net=api.tikliveapi.com \
--allow-env=TIKLIVE_API_KEY \
--allow-write=./out \
main.ts
Deno Deploy supports scheduled functions natively via Deno.cron. The handler shape is the same as any HTTP worker, and the runtime is a V8 isolate so cold starts are negligible. Push your API key in as a project secret from the Deploy dashboard - never hardcode it.
// worker.ts
import { resolveUserId, getUserInfo } from "./users.ts";
Deno.cron("daily creator snapshot", "0 6 * * *", async () => {
const handles = (Deno.env.get("HANDLES") ?? "").split(",").filter(Boolean);
for (const h of handles) {
const id = await resolveUserId(h);
const info = await getUserInfo(h);
console.log(h, id, info.stats.followerCount);
}
});
Deno.serve(() => new Response("ok"));
Deploy with deployctl deploy --project=tik-scraper worker.ts. The cron runs in UTC. For ad-hoc invocations, hit the public URL.
TIKLIVE_API_KEY as a Deno Deploy secret. Project secrets are encrypted at rest and only injected at runtime.--allow-net=api.tikliveapi.com stops a compromised dependency from reaching anywhere else.X-Api-Key header. Never put the key in the URL or a query string.The external api.tikliveapi.com host handles all credit deduction. The dashboard at www.tikliveapi.com only shows your balance and history. Each successful 2xx response costs one credit.
play, wmplay, and hdplay?On /post-detail/, play is the standard watermark-free MP4 URL, wmplay is the original watermarked version, and hdplay is the HD watermark-free variant when available. All three are flat top-level fields on the response.
/post-comments/ returns comments[], total, cursor (number), and hasMore. Each comment item uses id as its primary key (not cid) - useful to remember if you have written scrapers against other TikTok APIs.
The fetch-based client is portable. Bun runs worker.ts with minimal changes - swap Deno.env for process.env and Deno.writeTextFile for fs.promises.writeFile. The AsyncGenerator, retry, and pool helpers are pure ECMAScript.
Browse the users, posts, search, and download categories. The playground is the fastest way to confirm a response shape before you write a type for it.
Open a ticket from contact with your use case. The default 200/min ceiling is increasable on request, and the credit model means you only pay for successful calls. More tutorials live on the blog.
Ready to put what you read into code? Try our endpoints live or grab the full reference.