Most teams building products on top of TikLiveAPI hit the same wall around the third or fourth screen: the frontend is making five sequential REST calls to render one user profile (user info, posts, comment counts, music for each post, hashtag lookups), the round-trip count is killing perceived performance, and the TypeScript types are drifting out of sync with what the API actually returns. The fix is not "add more endpoints." It is to put a thin GraphQL layer in front of the REST API so the client asks for exactly the data it needs in one request, with one typed schema, and one well-defined error contract.
This guide walks through wrapping TikLiveAPI with an Apollo Server in TypeScript. We will design a schema rooted in User, Post, Comment, Hashtag, and Music, wire up resolvers that call the underlying REST endpoints, use DataLoader to kill N+1s, and end with a working ~100 line skeleton you can deploy today.
The case for a GraphQL wrapper is not about replacing REST. TikLiveAPI's 37 endpoints are already well-designed, focused, and stable. The wrapper exists to make the client side easier.
User.stats.followerCount is a number, always, with documentation attached. No more guessing whether a counter is a string or a number after a JSON parse.A GraphQL wrapper is not free. Be honest about the costs before you commit:
/userinfo-by-id/ calls if you do not batch.If your product is a single dashboard page hitting two endpoints, skip GraphQL. If you are building a multi-surface product (web app, mobile, admin tools) all talking to TikLiveAPI, the wrapper pays for itself in a quarter.
Three serious options exist in 2026:
We will use Apollo Server because it gives the shortest path from "no code" to "production ready wrapper," and TypeScript across the stack means the schema, resolver signatures, and frontend hooks share types end to end.
The schema is the part you should spend the most time on. Map the REST shapes into idiomatic GraphQL types and resist the urge to expose every JSON field one-to-one. TikLiveAPI mixes camelCase (the user{}/stats{} objects from /userinfo-by-username/) and snake_case (post items from /post-detail/), so the wrapper is also where you normalize.
type User {
id: ID!
uniqueId: String!
nickname: String!
signature: String
verified: Boolean!
avatarLarger: String
secUid: String
privateAccount: Boolean
stats: UserStats!
posts(first: Int = 10, after: String): PostConnection!
followers(first: Int = 50, after: String): UserConnection!
}
type UserStats {
followerCount: Int!
followingCount: Int!
heartCount: Int!
videoCount: Int!
diggCount: Int!
}
type Post {
id: ID!
awemeId: String!
videoId: String!
region: String
title: String
cover: String
play: String
wmplay: String
hdplay: String
playCount: Int!
diggCount: Int!
commentCount: Int!
shareCount: Int!
createTime: Int!
author: User!
music: Music
comments(first: Int = 20, after: String): CommentConnection!
}
type Comment {
id: ID!
text: String!
diggCount: Int!
replyTotal: Int!
videoId: String!
createTime: Int!
user: User!
replies(first: Int = 20, after: String): CommentConnection!
}
type Hashtag {
id: ID!
chaName: String!
desc: String
userCount: Int!
viewCount: Int!
cover: String
}
type Music {
id: ID!
title: String!
play: String!
cover: String
author: String
original: Boolean!
duration: Int!
videoCount: Int!
}
Three small but important calls were made above. First, the Post.id is the field you choose as canonical (we use aweme_id) while still exposing both awemeId and videoId because clients need both. Second, the hashtag field is named chaName to match what /challenge-info-name/ actually returns. Third, the Comment.id field maps to the id key in the comments response (not cid).
The top-level entry points map directly to TikLiveAPI's lookup endpoints:
type Query {
user(username: String!): User
userById(id: ID!): User
post(url: String!): Post
hashtag(name: String!): Hashtag
hashtagById(id: ID!): Hashtag
music(id: ID!): Music
searchUsers(keyword: String!, first: Int = 10, after: String): UserConnection!
searchPosts(keyword: String!, first: Int = 10, after: String): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge { cursor: String!; node: Post! }
type PageInfo { hasNextPage: Boolean!; endCursor: String }
Relay-style connections map cleanly onto TikLiveAPI's cursor + hasMore envelope. The wrapper translates hasMore into pageInfo.hasNextPage and the returned cursor into pageInfo.endCursor. For /user-followers/ and /user-following/, which page on time instead of cursor, the resolver does the swap internally and still exposes the standard after argument upstream. The /user-following/ top-level key in the raw JSON is followings (plural with the trailing s) - one of those small details that wrapping fixes once at the boundary.
Resolvers do three things: call the REST endpoint, reshape the response into the GraphQL type, and pass partial data forward so child resolvers can fill in the rest. The pattern looks like this:
type Ctx = {
apiKey: string;
loaders: {
userByUsername: DataLoader<string, RawUser>;
userById: DataLoader<string, RawUser>;
};
};
const resolvers = {
Query: {
user: async (_: unknown, { username }: { username: string }, ctx: Ctx) => {
const raw = await ctx.loaders.userByUsername.load(username);
return raw ? mapUser(raw) : null;
},
},
User: {
id: async (parent: MappedUser, _args, ctx: Ctx) => {
if (parent.id) return parent.id;
const r = await fetchJson(ctx, '/userid/', { username: parent.uniqueId });
return r.id as string;
},
posts: async (parent: MappedUser, args: PageArgs, ctx: Ctx) => {
const data = await fetchJson(ctx, '/user-posts/', {
userid: parent.id,
count: args.first ?? 10,
cursor: args.after ?? '',
});
return toConnection(data.videos.map(mapPost), data.cursor, data.hasMore);
},
},
Post: {
comments: async (parent: MappedPost, args: PageArgs, ctx: Ctx) => {
const data = await fetchJson(ctx, '/post-comments/', {
url: parent.shareUrl,
count: args.first ?? 20,
cursor: args.after ?? '',
});
return toConnection(data.comments.map(mapComment), data.cursor, data.hasMore);
},
},
};
The User.id resolver is the interesting one. When a client queries user(username: "khaby.lame") { id posts { ... } }, the wrapper hits /userinfo-by-username/ first (which gives us user.id already), so the id resolver short-circuits. If a client only knew the username and wanted the id, the /userid/ fallback kicks in.
The most common GraphQL footgun: a client requests searchPosts(keyword: "cats", first: 50) { node { author { nickname } } } and your resolvers fire 50 sequential /userinfo-by-id/ calls. DataLoader is the canonical fix - it batches and dedupes within a single request.
import DataLoader from 'dataloader';
function makeLoaders(apiKey: string) {
return {
userById: new DataLoader<string, RawUser>(async (ids) => {
// TikLiveAPI has no batch endpoint, so we fan out in parallel
// but DataLoader dedupes duplicate ids inside one request.
const unique = [...new Set(ids)];
const results = await Promise.all(
unique.map((id) => fetchJson({ apiKey }, '/userinfo-by-id/', { userid: id }))
);
const byId = new Map(unique.map((id, i) => [id, results[i]]));
return ids.map((id) => byId.get(id)!);
}, { maxBatchSize: 25 }),
userByUsername: new DataLoader<string, RawUser>(async (usernames) => {
const unique = [...new Set(usernames)];
const results = await Promise.all(
unique.map((u) => fetchJson({ apiKey }, '/userinfo-by-username/', { username: u }))
);
const byName = new Map(unique.map((u, i) => [u, results[i]]));
return usernames.map((u) => byName.get(u)!);
}),
};
}
Even without a true batch endpoint upstream, DataLoader earns its keep through deduplication. If three different posts in a search result happen to share an author, you make one upstream call instead of three. The loaders are created per request inside the context factory so caching is request-scoped, not global - critical for a credit-billed API where you do not want to serve hour-old data.
GraphQL's killer feature for an API wrapper is partial responses. If a client asks for a user plus that user's last 30 posts plus the music for each post and the music endpoint times out for two posts, you can still return everything else with music: null on the affected entries and an errors array describing what broke.
import { GraphQLError } from 'graphql';
async function fetchJson(ctx: Ctx, path: string, params: Record<string, string | number>) {
const url = new URL(path, 'https://api.tikliveapi.com');
Object.entries(params).forEach(([k, v]) => v !== '' && url.searchParams.set(k, String(v)));
const res = await fetch(url, { headers: { 'X-Api-Key': ctx.apiKey } });
if (res.status === 401) {
throw new GraphQLError('Invalid API key', { extensions: { code: 'UNAUTHENTICATED' } });
}
if (res.status === 429) {
throw new GraphQLError('Out of credits', { extensions: { code: 'INSUFFICIENT_CREDITS' } });
}
if (!res.ok) {
throw new GraphQLError(`Upstream ${res.status}`, { extensions: { code: 'UPSTREAM_ERROR', path } });
}
return res.json();
}
The extensions.code field is what frontend code keys off. UNAUTHENTICATED redirects to login, INSUFFICIENT_CREDITS opens the pricing modal, UPSTREAM_ERROR shows a retry button. Do not leak raw upstream error messages to the client.
Two cache layers matter for a TikLiveAPI wrapper:
@apollo/server-plugin-response-cache package keys responses by query hash + variables and respects per-field @cacheControl(maxAge: 60) directives. Hashtag info changes slowly (cache for 5 minutes), user stats change faster (cache for 30 seconds), and downloads from /download-video/ should not be cached at all because CDN URLs expire.The cache key must include the API key in some form (hashed) so two customers cannot accidentally read each other's cached responses. The simplest pattern: include the API key hash in the cache scope.
The frontend already has the customer's API key (visible on the profile page). The cleanest pattern is: client sends it in Authorization: Bearer <key>, the gateway extracts and forwards it as X-Api-Key to TikLiveAPI. Each request lives in its own context so a key from request A never leaks into request B's loader cache.
const server = new ApolloServer({ schema });
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
const auth = req.headers.authorization ?? '';
const apiKey = auth.replace(/^Bearer\s+/i, '').trim();
if (!apiKey) throw new GraphQLError('Missing API key', { extensions: { code: 'UNAUTHENTICATED' } });
return { apiKey, loaders: makeLoaders(apiKey) };
},
});
TikLiveAPI does not push events - there are no webhooks, and the upstream is request-response only. If you want a "live comment count" feel, the pattern is: GraphQL subscription on the server that polls /post-detail/ every 5 seconds and pushes the delta down to subscribed clients over WebSockets. The client thinks it is real-time; only your gateway knows it is polling. Budget this carefully - each subscriber costs 12 credits per minute at that polling rate.
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { GraphQLError } from 'graphql';
import DataLoader from 'dataloader';
const typeDefs = `#graphql
type Query {
user(username: String!): User
post(url: String!): Post
}
type User {
id: ID!
uniqueId: String!
nickname: String!
verified: Boolean!
stats: UserStats!
posts(first: Int = 10, after: String): PostConnection!
}
type UserStats { followerCount: Int!; heartCount: Int!; videoCount: Int! }
type Post {
id: ID!
awemeId: String!
title: String
play: String
hdplay: String
playCount: Int!
diggCount: Int!
commentCount: Int!
}
type PostConnection { edges: [PostEdge!]!; pageInfo: PageInfo! }
type PostEdge { cursor: String!; node: Post! }
type PageInfo { hasNextPage: Boolean!; endCursor: String }
`;
const BASE = 'https://api.tikliveapi.com';
async function call(apiKey: string, path: string, params: Record<string, string | number> = {}) {
const url = new URL(path, BASE);
for (const [k, v] of Object.entries(params)) if (v !== '') url.searchParams.set(k, String(v));
const r = await fetch(url, { headers: { 'X-Api-Key': apiKey } });
if (!r.ok) throw new GraphQLError(`Upstream ${r.status}`, { extensions: { code: 'UPSTREAM' } });
return r.json();
}
const resolvers = {
Query: {
user: async (_: unknown, a: { username: string }, ctx: any) => {
const raw = await ctx.loaders.userByUsername.load(a.username);
return { ...raw.user, stats: raw.stats };
},
},
User: {
posts: async (parent: any, a: { first: number; after?: string }, ctx: any) => {
const d = await call(ctx.apiKey, '/user-posts/', { userid: parent.id, count: a.first, cursor: a.after ?? '' });
return {
edges: d.videos.map((v: any) => ({
cursor: v.aweme_id,
node: {
id: v.aweme_id, awemeId: v.aweme_id, title: v.title, play: v.play, hdplay: v.hdplay,
playCount: v.play_count, diggCount: v.digg_count, commentCount: v.comment_count,
},
})),
pageInfo: { hasNextPage: d.hasMore, endCursor: d.cursor },
};
},
},
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
const apiKey = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, '').trim();
if (!apiKey) throw new GraphQLError('Missing API key', { extensions: { code: 'UNAUTHENTICATED' } });
return {
apiKey,
loaders: {
userByUsername: new DataLoader<string, any>(async (names) => {
const u = [...new Set(names)];
const res = await Promise.all(u.map((n) => call(apiKey, '/userinfo-by-username/', { username: n })));
const m = new Map(u.map((n, i) => [n, res[i]]));
return names.map((n) => m.get(n));
}),
},
};
},
});
console.log(`GraphQL ready at ${url}`);
That is a complete working wrapper. Drop it into a Node 20 project with @apollo/server, graphql, and dataloader installed, point your frontend at port 4000, and try queries from the playground against the underlying endpoints to compare shapes.
No. Each resolver still maps to one upstream call. DataLoader deduplication can actually decrease credits when a query references the same author or hashtag multiple times. The wrapper is request-shape transformation, not a billing layer.
For semi-static data like hashtag info (/challenge-info-name/) and music info, yes - 5 to 15 minute TTLs work well. For user stats and post counters, cache aggressively only if your product can tolerate slightly stale numbers. Never cache download URLs from /download-video/; CDN signatures expire.
Hide them. The wrapper exposes a single Relay-style cursor argument to clients. Inside the followers resolver, you translate the cursor back into the time param TikLiveAPI expects. Inside the posts resolver, you pass it through as cursor. The frontend never knows the difference.
Apollo Server ships with Apollo Sandbox at the root URL in dev mode. Disable it in production and keep the upstream TikLiveAPI playground for raw REST testing. Two tools, two purposes.
That is the right starting point. Register an account, grab an API key, hit two or three endpoints with curl. Build the GraphQL layer only when your client code has three or more concurrent REST calls per page render. See pricing for the pay-as-you-go credits, and contact us if you want a hand reviewing a schema draft. The full endpoint catalog is on the blog and in the documentation.
Ready to put what you read into code? Try our endpoints live or grab the full reference.