Kotlin has quietly become the default language for Android, and it is also one of the cleanest ways to consume a JSON API like TikLiveAPI. Three language features make the fit obvious: coroutines give you cooperative concurrency without callback hell, kotlinx.serialization turns JSON shapes into typed data classes at compile time, and Flow gives you a first-class primitive for paginated streams. Wrap those around Retrofit or Ktor Client, and you get a TikTok analytics client that is short, testable, and idiomatic.
This tutorial walks through a full Android implementation that fetches a creator profile, resolves their numeric id, pulls their videos with cursor pagination, exposes the stream as a Flow<List<Video>>, and renders the result in a Jetpack Compose screen. We will use TikLiveAPI as the data source: it ships 37 endpoints, runs on a multi-server fleet with a 99.9% uptime target and roughly 750 ms average latency, and uses simple X-Api-Key header authentication. Pricing is pay-as-you-go (1 request = 1 credit) on the pricing page, with no subscription lock-in.
One honest note before you start: TikLiveAPI does not expose a dedicated TikTok Live (livestream) endpoint. If your app needs live stream data, you will only get what is publicly posted to the creator feed - use /post-detail/, /user-posts/, and /user-stories/. Everything else in the user, posts, music, challenge, search, playlist, collection, download, region, and ads families is fully supported.
Hardcoding the key in source means it ships in your APK and can be extracted in seconds with apktool. Even in development, store it in EncryptedSharedPreferences, which wraps the AES master key in the Android Keystore.
class ApiKeyStore(context: Context) {
private val prefs = EncryptedSharedPreferences.create(
context,
"tla_secure",
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun put(key: String) = prefs.edit().putString("api_key", key).apply()
fun get(): String? = prefs.getString("api_key", null)
}
In production you should not store the raw key on the device at all. See the proxy section near the end of this guide.
TikLiveAPI mixes naming conventions across endpoints. The user endpoints use camelCase nested objects, while post and search payloads are flat snake_case. Always check the documentation and mark snake_case fields with @SerialName.
@Serializable
data class UserInfoResponse(
val user: User,
val stats: UserStats,
)
@Serializable
data class User(
val id: String,
val uniqueId: String,
val nickname: String,
val avatarLarger: String? = null,
val signature: String? = null,
val verified: Boolean = false,
val secUid: String? = null,
)
@Serializable
data class UserStats(
val followerCount: Long,
val followingCount: Long,
val heartCount: Long,
val videoCount: Long,
)
@Serializable
data class UserIdResponse(val id: String)
@Serializable
data class PostsResponse(
val videos: List<Video> = emptyList(),
val cursor: String? = null,
val hasMore: Boolean = false,
)
@Serializable
data class Video(
@SerialName("aweme_id") val awemeId: String,
@SerialName("video_id") val videoId: String? = null,
val play: String? = null,
val wmplay: String? = null,
@SerialName("play_count") val playCount: Long = 0,
@SerialName("digg_count") val diggCount: Long = 0,
val author: VideoAuthor? = null,
)
@Serializable
data class VideoAuthor(
val id: String,
@SerialName("unique_id") val uniqueId: String,
val nickname: String,
val avatar: String? = null,
)
Note the asymmetry: UserInfoResponse uses native camelCase keys like followerCount, while Video needs @SerialName("play_count"). The hasMore pagination flag is camelCase even on snake_case payloads. These details match the live response shape - if you skip them, deserialization silently produces zeros.
Bake the API key into every request via an OkHttp interceptor. This keeps the header out of every @GET signature and lets you swap the source later (cached value, remote config, proxy token).
class ApiKeyInterceptor(private val keyProvider: () -> String?) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val key = keyProvider() ?: return chain.proceed(chain.request())
val req = chain.request().newBuilder()
.addHeader("X-Api-Key", key)
.build()
return chain.proceed(req)
}
}
object TikLiveClient {
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
fun build(keyStore: ApiKeyStore): TikLiveApi {
val ok = OkHttpClient.Builder()
.addInterceptor(ApiKeyInterceptor { keyStore.get() })
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl("https://api.tikliveapi.com/")
.client(ok)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(TikLiveApi::class.java)
}
}
If you prefer Ktor, the equivalent is HttpClient(OkHttp) { install(ContentNegotiation) { json() }; defaultRequest { header("X-Api-Key", keyStore.get()) } } - same interceptor pattern, different DSL.
The cleanest endpoint to start with is /userinfo-by-username/. It accepts a single username query parameter and returns the nested user{} and stats{} objects.
interface TikLiveApi {
@GET("userinfo-by-username/")
suspend fun userInfo(@Query("username") username: String): UserInfoResponse
@GET("userid/")
suspend fun userId(@Query("username") username: String): UserIdResponse
@GET("user-posts/")
suspend fun userPosts(
@Query("userid") userid: String,
@Query("count") count: Int = 20,
@Query("cursor") cursor: String? = null,
): PostsResponse
}
Call it inside a coroutine - typically tied to a ViewModel viewModelScope so cancellation is automatic when the screen goes away.
Every endpoint that needs an id (followers, following, posts, collections, playlists, stories) expects the numeric userid, not the @handle. The /userid/ endpoint returns the minimal flat payload {"id": "..."} - the only key in the response.
suspend fun resolveUserId(api: TikLiveApi, username: String): String =
api.userId(username).id
Cache the result in Room or a simple in-memory map keyed by username. A handle to id mapping rarely changes, so caching saves you one credit on every subsequent call.
With the numeric id in hand, fetch a page of videos. count caps at 35 per request on most user endpoints; cursor pagination is server-driven.
suspend fun fetchFirstPage(api: TikLiveApi, userid: String): PostsResponse =
api.userPosts(userid = userid, count = 30, cursor = null)
The response carries three top-level keys: videos (array of flat snake_case items), cursor (string opaque token to pass back), and hasMore (camelCase boolean). When hasMore is false, stop requesting.
Wrapping the cursor loop in a Flow gives UI code a clean way to consume pages incrementally. The collector can show videos as they arrive instead of waiting for the whole list.
fun userPostsStream(api: TikLiveApi, userid: String, pageSize: Int = 30): Flow<List<Video>> = flow {
var cursor: String? = null
var more = true
while (more) {
val page = api.userPosts(userid, pageSize, cursor)
if (page.videos.isNotEmpty()) emit(page.videos)
cursor = page.cursor
more = page.hasMore && cursor != null
}
}.flowOn(Dispatchers.IO)
For followers and following, the pagination contract is different. /user-followers/ uses a time timestamp parameter instead of cursor, and /user-following/ returns its array under the key followings (plural with trailing s, not following). Model those as separate Flow builders so the parameter shape stays explicit.
Network calls fail. The Flow operator retryWhen plus a hand-rolled exponential backoff is the idiomatic way to recover from transient HTTP errors without retrying on hard 4xx responses.
fun <T> Flow<T>.retryWithBackoff(
maxAttempts: Int = 4,
initialDelay: Long = 500,
factor: Double = 2.0,
): Flow<T> = retryWhen { cause, attempt ->
val recoverable = cause is IOException ||
(cause is HttpException && cause.code() in 500..599)
if (recoverable && attempt < maxAttempts) {
delay((initialDelay * factor.pow(attempt.toInt())).toLong())
true
} else false
}
Wrap your stream with userPostsStream(api, userid).retryWithBackoff() and you have automatic recovery from flaky networks and 5xx blips. A 401 or 402 (out of credits) will propagate immediately so you can prompt the user to top up at /pricing/.
If you batch-fetch a list of creators - say, ten profiles for a comparison screen - you want parallelism but not enough to swamp the API or your own credit budget. coroutineScope plus a Semaphore caps in-flight requests.
suspend fun fetchMany(
api: TikLiveApi,
usernames: List<String>,
maxConcurrency: Int = 4,
): List<UserInfoResponse> = coroutineScope {
val gate = Semaphore(maxConcurrency)
usernames.map { name ->
async {
gate.withPermit { api.userInfo(name) }
}
}.awaitAll()
}
Wrap the result in a sealed class for the UI layer so success, error, and partial states are exhaustive in a when block.
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Failure(val cause: Throwable) : Result<Nothing>
data object Loading : Result<Nothing>
}
The ViewModel exposes the user info as a StateFlow<Result<UserInfoResponse>> and the composable renders the three states. Compose collects the flow as state and recomposes on each emission.
@Composable
fun CreatorProfile(state: Result<UserInfoResponse>) {
when (state) {
is Result.Loading -> CircularProgressIndicator()
is Result.Failure -> Text("Could not load: ${state.cause.message}")
is Result.Success -> Column(Modifier.padding(16.dp)) {
AsyncImage(model = state.data.user.avatarLarger, contentDescription = null)
Text(state.data.user.nickname, style = MaterialTheme.typography.headlineSmall)
Text("@${state.data.user.uniqueId}")
Spacer(Modifier.height(8.dp))
Row {
StatCell("Followers", state.data.stats.followerCount)
StatCell("Likes", state.data.stats.heartCount)
StatCell("Videos", state.data.stats.videoCount)
}
}
}
}
Pair the profile with a LazyColumn that consumes userPostsStream via collectAsLazyPagingItems() or a custom SnapshotStateList. New pages appear as the user scrolls without blocking the UI.
WorkManager for daily snapshots. If your app records creator growth over time, schedule a periodic worker that fetches userInfo once a day and writes a row to Room. WorkManager handles Doze mode, battery constraints, and backoff automatically.
val work = PeriodicWorkRequestBuilder<SnapshotWorker>(1, TimeUnit.DAYS)
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.build()
WorkManager.getInstance(ctx).enqueueUniquePeriodicWork(
"snapshot", ExistingPeriodicWorkPolicy.KEEP, work,
)
Room for caching. Define an entity per response shape and serve from the database first, then refresh in the background. This pattern is how production apps stay snappy on cold start - the UI gets last-known-good data immediately while the network refresh runs in parallel.
ProGuard / R8. kotlinx.serialization needs @Keep or explicit keep rules for your @Serializable classes. The official plugin ships the rules automatically, but if you build a release variant and see SerializationException: Serializer for class 'User' is not found, add -keep,includedescriptorclasses class **$$serializer { *; } to your proguard-rules.pro.
Anyone who downloads your APK can extract embedded strings - the encrypted preferences only protect the on-device storage, not the key you compile in. The standard fix is a thin proxy in front of TikLiveAPI: deploy a Cloud Function, Lambda, or Cloudflare Worker that injects X-Api-Key server-side and exposes the same JSON to your app. The Android client talks to https://your-proxy.example.com with a short-lived Firebase Auth ID token, and the API key never leaves your server.
This is the same pattern the TikLiveAPI in-browser playground uses: a server-side playground_proxy.php forwards the call and adds the key. For a deeper look at the auth header contract, see the documentation.
Yes. Ktor is the natural choice if you also target Kotlin Multiplatform. Use the OkHttp engine on Android for HTTP/2 and connection pooling, install ContentNegotiation with the kotlinx.serialization JSON plugin, and add a defaultRequest block that sets the X-Api-Key header. The data classes from Step 2 work unchanged.
No. TikLiveAPI does not expose a dedicated livestream endpoint. For what is publicly available about a creator's Live posts in their feed, use /post-detail/, /user-posts/, and /user-stories/. Real-time stream data is not in scope.
Call /post-detail/ with the video URL. The flat snake_case response includes play (no-watermark SD), wmplay (watermarked), and hdplay (HD no-watermark), plus hd_size for the byte size. Always URL-encode the input url parameter before sending.
followerCount values always zero?The most common cause is a missing @SerialName annotation. The /userinfo-by-username/ response uses camelCase, so plain Kotlin property names map directly. But for /user-posts/ and similar snake_case payloads, you must annotate every field. Setting ignoreUnknownKeys = true on the Json instance also helps deserialization survive future schema additions.
Log in to your dashboard at /profile/ - it shows real-time today's count and historical daily totals. The pipeline aggregates counters per account: today_count.php serves the live number while a 00:30 daily cron archives the previous day. If you have questions about credit usage or pricing, reach out via /contact/ or browse the /blog/ for more guides.
Ready to put what you read into code? Try our endpoints live or grab the full reference.