How to Scrape TikTok User Data with Deno and TypeScript

Published on May 29, 2026

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.

Why Deno for TikTok scraping

Three things matter when you are pulling structured social data at scale:

  • Native TypeScript. No tsc step, no ts-node, no tsconfig.json debates. Deno reads .ts files directly and ships a fast V8 + Rust runtime.
  • Secure-by-default permissions. Your scraper cannot silently read your SSH keys or POST to a third party. You opt in with --allow-net=api.tikliveapi.com --allow-env=TIKLIVE_API_KEY --allow-write=./out.
  • Modern web fetch. The same 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.

Prerequisites

  • Deno 1.40 or newer (deno --version). The standard library APIs used here are stable in 1.40+.
  • A TikLiveAPI key from your profile.
  • An editor with the Deno extension (VS Code has an official one).

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).

A typed client wrapper

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;
}

Type-safe response models

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
}

Resolving a username and pulling profile data

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 });
}

Pagination as an AsyncGenerator

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;
  }
}

Retry with exponential backoff

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<...>(...)).

Concurrency with a promise pool

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.

Writing CSV with Deno.writeTextFile

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");
}

Putting it together

// 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

Shipping to Deno Deploy as a cron worker

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.

Security notes

  • Store TIKLIVE_API_KEY as a Deno Deploy secret. Project secrets are encrypted at rest and only injected at runtime.
  • Scope local permissions narrowly: --allow-net=api.tikliveapi.com stops a compromised dependency from reaching anywhere else.
  • Rotate the key from your profile if it ever ends up in a git history or build log.
  • Auth is a single X-Api-Key header. Never put the key in the URL or a query string.

FAQ

Does the dashboard count credits or does the external API?

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.

What is the difference between 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.

How do I paginate comments?

/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.

Can I run this on Bun or Node?

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.

Where do I learn the rest of the surface?

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.

What if I need more than 200 requests per minute?

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.

Build with the TikTok API

Ready to put what you read into code? Try our endpoints live or grab the full reference.

Open Playground Read Documentation