TikTok's public web endpoints do not always return the page size an application requests. One route may return a small native window, another may underfill a page even though more data exists, and a cursor may represent more than a simple numeric offset.

That becomes a production problem quickly:

  • count can disagree with the number of returned records.
  • An application can skip items by calculating its own cursor.
  • Consecutive upstream windows can contain the same item.
  • A response can claim that more data exists without giving the client a safe continuation value.
  • Missing booleans can be silently converted to false, changing their meaning.

While building a public TikTok metadata API, I treated pagination as an application contract rather than a thin pass-through to one upstream request.

Define the response contract first

Every list response follows these invariants:

page.count === page.data.length;
page.data.length <= requestedCount;

Enter fullscreen mode Exit fullscreen mode

The response also contains:

{
  "request_id": "correlation-id",
  "data": [],
  "count": 0,
  "cursor": null,
  "has_more": false
}

Enter fullscreen mode Exit fullscreen mode

The important rules are:

  1. count describes the records actually returned, not the requested page size.
  2. has_more=true means the returned cursor can be used for another request.
  3. The client copies the cursor unchanged.
  4. A final page may contain fewer items than requested.
  5. The service never pads a page with duplicate records.

Numeric and opaque cursors are different

Comments and comment replies expose TikTok's numeric continuation cursor. It may look like an offset, but the safest client still treats it as server-owned state.

Creator videos, music videos, hashtag feeds, search, and Explore/trending results use a signed opaque cursor. The opaque value can preserve:

  • The upstream continuation state
  • Records left over after filling the previous client page
  • IDs already emitted while native windows were merged
  • The resource or search query to which the cursor belongs

A cursor returned for one creator or keyword cannot be reused for another.

Build one reusable client

This Node.js 18+ example calls the API through RapidAPI and retries temporary failures:

const BASE_URL =
  "https://tiktok-public-metadata-api.p.rapidapi.com";

const headers = {
  "X-RapidAPI-Key": process.env.RAPIDAPI_KEY,
  "X-RapidAPI-Host": "tiktok-public-metadata-api.p.rapidapi.com"
};

const sleep = (milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function getPage(path, parameters, attempt = 0) {
  const url = new URL(path, BASE_URL);

  for (const [name, value] of Object.entries(parameters)) {
    if (value !== undefined && value !== null) {
      url.searchParams.set(name, String(value));
    }
  }

  const response = await fetch(url, { headers });

  if ((response.status === 429 || response.status === 503) && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delay = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 500 * 2 ** attempt + Math.random() * 250;

    await sleep(delay);
    return getPage(path, parameters, attempt + 1);
  }

  const body = await response.json();

  if (!response.ok) {
    throw new Error(
      `${response.status} ${body?.error?.code ?? "API_ERROR"} ` +
      `(request_id=${body?.request_id ?? "unknown"})`
    );
  }

  if (body.count !== body.data.length) {
    throw new Error("Unexpected pagination contract");
  }

  return body;
}

Enter fullscreen mode Exit fullscreen mode

The client logs request_id in its error so a failed customer request can be traced without logging credentials or a full native payload.

Paginate comments without calculating offsets

async function collectComments(videoUrl, maximum = 50) {
  const comments = [];
  let cursor = 0;
  let hasMore = true;

  while (hasMore && comments.length < maximum) {
    const page = await getPage("/v1/videos/comments", {
      url: videoUrl,
      count: Math.min(10, maximum - comments.length),
      cursor,
      raw: false
    });

    comments.push(...page.data);
    cursor = page.cursor;
    hasMore = page.has_more === true;
  }

  return comments;
}

const comments = await collectComments(
  "https://www.tiktok.com/@sophiegamergirl/video/7658435999478992148"
);

console.log(comments.length);

Enter fullscreen mode Exit fullscreen mode

The loop does not add 10 to the cursor. It uses the value returned by the successful response.

Use the same pattern for opaque cursors

async function* paginate(path, fixedParameters, pageSize = 20) {
  let cursor = 0;
  let hasMore = true;

  while (hasMore) {
    const page = await getPage(path, {
      ...fixedParameters,
      count: pageSize,
      cursor,
      raw: false
    });

    yield page;

    cursor = page.cursor;
    hasMore = page.has_more === true;
  }
}

for await (const page of paginate(
  "/v1/videos",
  { username: "sophiegamergirl" },
  20
)) {
  for (const video of page.data) {
    console.log(video.id, video.description);
  }
}

Enter fullscreen mode Exit fullscreen mode

The same iterator works for:

  • /v1/music/videos
  • /v1/hashtag/videos
  • /v1/search/videos
  • /v1/search/users
  • /v1/search/hashtags
  • /v1/search/music
  • /v1/trending/videos

Merge native windows without losing raw data

A public upstream route may return fewer items than the client requested even when another native window exists. The service can fetch consecutive windows, normalize their records, remove duplicate IDs, and stop when one of these conditions is reached:

  • The requested client count is filled
  • TikTok reports no more data
  • A safe upstream work limit is reached

When raw=true is requested, the native responses are not discarded. They remain ordered under:

raw.pages[]

Enter fullscreen mode Exit fullscreen mode

This keeps two different contracts:

  • Normalized fields are the stable interface for application code.
  • Raw pages expose the native upstream JSON when a product needs a field that is not normalized.

Raw mode should be deliberate. Native payloads are larger, bypass cache, and can change when TikTok changes its public web response.

Preserve null instead of inventing false

Public web surfaces do not expose every account, video, or comment field consistently. Booleans therefore need three states:

  • true: TikTok explicitly returned a positive signal
  • false: TikTok explicitly returned a negative signal
  • null: the selected upstream surface did not provide a trustworthy value

For example:

const researchRow = {
  id: video.id,
  is_ad: video.is_ad ?? null,
  is_ai_generated: video.is_ai_generated ?? null
};

Enter fullscreen mode Exit fullscreen mode

Converting null to false would turn "unknown" into a factual claim.

Keep the infrastructure HTTP-first

Reliable pagination does not require one Chrome process per customer request.

The collection path can remain HTTP-first:

  1. Use direct public HTTP surfaces when they return the required data.
  2. Generate or refresh a web signature only for routes that require it.
  3. Use one bounded browser fallback only when TikTok requires current browser state.
  4. Return a structured 503 when the upstream is temporarily unavailable instead of fabricating an empty successful response.

This keeps idle memory low and avoids maintaining a persistent browser fleet.

Test the contract as a customer would

The production monitor for this API runs 29 customer-path probes through the RapidAPI gateway. It covers all 16 data endpoints with multiple creators, videos, search terms, hashtags, music, comments, replies, pagination, and empty final pages.

The checks validate behavior rather than only HTTP status:

  • count === data.length
  • No duplicate IDs inside a page
  • Returned identities match the requested resource
  • Empty reply pages are accepted when no public replies remain
  • Retryable upstream failures are not hidden inside a fake 200
  • Process memory, Swap, restarts, and endpoint latency remain observable

Try the complete API

The current release provides 16 read-only endpoints for public videos, creators, comments, replies, music, hashtags, search, Explore/trending data, batch lookup, and URL resolution.

You can run the examples in the RapidAPI playground:

https://rapidapi.com/pomiandaitumm/api/tiktok-public-metadata-api

Full documentation, runnable examples, limitations, and live status:

https://tiktok-public-metadata-api.pomiandaitumm.chatgpt.site

If you build cursor-based APIs, I would be interested in how you model continuation state: native offsets, opaque signed state, database keys, or another approach?