How to Scrape TikTok User Data with Kotlin on Android

Published on May 29, 2026

Why Kotlin for TikTok Data on Android

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.

Prerequisites

  • Kotlin 1.9 or later, with the Kotlin Serialization plugin applied.
  • Android Gradle Plugin 8.x targeting minSdk 24+ (Android 7.0).
  • Retrofit 2.11 with the kotlinx-serialization converter, or Ktor Client 2.x.
  • OkHttp 4.12 for the underlying HTTP stack and interceptors.
  • AndroidX Security Crypto for EncryptedSharedPreferences.
  • Jetpack Compose 1.6+ for the UI layer, plus WorkManager and Room if you adopt the persistence section below.
  • A TikLiveAPI account - sign up, then copy your key from /profile/.

Step 1: Store the API Key with EncryptedSharedPreferences

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.

Step 2: Define @Serializable Data Classes

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.

Step 3: Retrofit Client with X-Api-Key Interceptor

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.

Step 4: Fetch User Info

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.

Step 5: Resolve a Numeric User Id

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.

Step 6: Get Posts

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.

Step 7: Pagination via Flow

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.

Step 8: Retry with Exponential Delay

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

Step 9: Bounded Concurrency with Semaphore

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

Step 10: Jetpack Compose Profile Screen

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.

Android-Specific: WorkManager, Room, ProGuard

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.

Do Not Ship the API Key in Your APK

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.

FAQ

Can I use Ktor Client instead of Retrofit?

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.

Does TikLiveAPI have a TikTok Live streaming endpoint?

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.

How do I download a video without the TikTok watermark?

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.

Why are my 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.

How do I track API usage from my app?

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.

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