If you are building an iOS app that surfaces TikTok creator metrics, video grids, or audience analytics, Swift is hard to beat. The language ships with first-class JSON support via Codable, modern concurrency through async/await and TaskGroup, and a networking stack (URLSession) that already handles HTTPS, redirects, and request cancellation. Pair that with SwiftUI for the UI layer and you have everything you need to consume the TikLiveAPI directly from your app.
This tutorial walks through scraping public TikTok user data using Swift 5.9+ on iOS 17+. We will hit a handful of endpoints from the live truth map: /userinfo-by-username/, /userid/, and /user-posts/, then wrap them into an AsyncSequence-driven paginator, add retry-with-backoff, and finally render the result in a SwiftUI view. Every endpoint is documented at tikliveapi.com/documentation, and you can run requests interactively from the in-browser Playground before you write any Swift.
One honest scope note up front: TikLiveAPI exposes 37 endpoints across Users, Posts, Music, Challenges, Search, Playlists, Download, Collections, Region, and Ads, but it does not provide a dedicated TikTok Live (livestream) endpoint. If you need data about a creator's live presence, use the regular profile, post, and story endpoints documented below.
async/await, AsyncSequence, and typed throwsURLSession, Codable, and SwiftUIEvery authenticated request sends the key in the X-Api-Key header against the base URL https://api.tikliveapi.com. One request equals one credit; credit accounting happens on the API side, not in your app.
Never hard-code your API key in source. Even debug builds end up on test devices, and TestFlight builds get distributed widely. Store the key in the iOS Keychain instead and read it lazily when you build requests.
import Foundation
import Security
enum KeychainError: Error { case unhandled(OSStatus) }
actor APIKeyStore {
static let shared = APIKeyStore()
private let service = "com.example.tiklive"
private let account = "apiKey"
func save(_ key: String) throws {
let data = Data(key.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
SecItemDelete(query as CFDictionary)
var attrs = query
attrs[kSecValueData as String] = data
attrs[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(attrs as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
func load() throws -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess else { return nil }
return (item as? Data).flatMap { String(data: $0, encoding: .utf8) }
}
}
Wrap it in an actor so concurrent reads are serialized for free. We will revisit App Store distribution concerns at the end of the article.
TikLiveAPI responses are not uniformly cased. The /userinfo-by-username/ endpoint nests user{} and stats{} in camelCase, while /user-posts/ returns a flat videos[] array in snake_case with a top-level hasMore in camelCase. Model each shape with explicit CodingKeys.
struct UserInfo: Decodable {
let user: User
let stats: Stats
struct User: Decodable {
let id: String
let uniqueId: String
let nickname: String
let avatarLarger: String?
let signature: String?
let verified: Bool?
let secUid: String?
let privateAccount: Bool?
}
struct Stats: Decodable {
let followingCount: Int
let followerCount: Int
let heartCount: Int
let videoCount: Int
let diggCount: Int?
}
}
struct UserIdResponse: Decodable {
let id: String
}
struct Videos: Decodable {
let videos: [Video]
let cursor: String?
let hasMore: Bool
enum CodingKeys: String, CodingKey {
case videos, cursor
case hasMore = "hasMore"
}
}
struct Video: Decodable {
let awemeId: String
let videoId: String?
let play: String?
let wmplay: String?
let playCount: Int?
let diggCount: Int?
let author: Author?
enum CodingKeys: String, CodingKey {
case awemeId = "aweme_id"
case videoId = "video_id"
case play, wmplay
case playCount = "play_count"
case diggCount = "digg_count"
case author
}
}
struct Author: Decodable {
let id: String
let uniqueId: String?
let nickname: String?
let avatar: String?
enum CodingKeys: String, CodingKey {
case id
case uniqueId = "unique_id"
case nickname, avatar
}
}
Build a thin client that injects the API key and decodes any response type. Errors funnel through a single typed enum so callers can branch on them.
enum TikLiveError: Error {
case missingKey
case http(Int)
case decoding(Error)
case transport(Error)
}
actor TikLiveClient {
private let session: URLSession
private let decoder = JSONDecoder()
private let base = URL(string: "https://api.tikliveapi.com")!
init(session: URLSession = .shared) { self.session = session }
func get<T: Decodable>(_ path: String, query: [String: String]) async throws -> T {
guard let key = try await APIKeyStore.shared.load() else {
throw TikLiveError.missingKey
}
var comps = URLComponents(url: base.appendingPathComponent(path),
resolvingAgainstBaseURL: false)!
comps.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
var req = URLRequest(url: comps.url!)
req.setValue(key, forHTTPHeaderField: "X-Api-Key")
req.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, resp) = try await session.data(for: req)
guard let http = resp as? HTTPURLResponse else { throw TikLiveError.http(0) }
guard (200..<300).contains(http.statusCode) else {
throw TikLiveError.http(http.statusCode)
}
do { return try decoder.decode(T.self, from: data) }
catch { throw TikLiveError.decoding(error) }
} catch let e as TikLiveError { throw e }
catch { throw TikLiveError.transport(error) }
}
func userInfo(username: String) async throws -> UserInfo {
try await get("/userinfo-by-username/", query: ["username": username])
}
}
The userid parameter required by every other user endpoint is a numeric string. Resolve it from a handle with /userid/, which returns a minimal {"id": "..."} payload.
extension TikLiveClient {
func resolveUserId(username: String) async throws -> String {
let r: UserIdResponse = try await get("/userid/", query: ["username": username])
return r.id
}
}
Cache the result. The mapping from uniqueId to numeric id is stable, so a one-time lookup per session keeps your credit consumption flat.
The /user-posts/ endpoint takes userid, count, and cursor, returning videos, cursor, and hasMore. Pass the cursor string from the previous page back in unchanged to walk the timeline.
extension TikLiveClient {
func userPosts(userid: String, count: Int = 20, cursor: String? = nil) async throws -> Videos {
var q = ["userid": userid, "count": String(count)]
if let cursor { q["cursor"] = cursor }
return try await get("/user-posts/", query: q)
}
}
Note that /user-followers/ and the followings endpoint use a time timestamp parameter instead of cursor, and the followings response top key is the plural followings. Do not blindly reuse this paginator for those.
Once cursors are involved, hand-rolling for-loops gets tedious. Lift the pagination into an AsyncSequence so callers can iterate with a plain for try await.
struct UserPostsStream: AsyncSequence {
typealias Element = Video
let client: TikLiveClient
let userid: String
let pageSize: Int
struct AsyncIterator: AsyncIteratorProtocol {
let client: TikLiveClient
let userid: String
let pageSize: Int
var buffer: [Video] = []
var cursor: String? = nil
var done = false
mutating func next() async throws -> Video? {
if !buffer.isEmpty { return buffer.removeFirst() }
if done { return nil }
let page = try await client.userPosts(
userid: userid, count: pageSize, cursor: cursor)
buffer = page.videos
cursor = page.cursor
done = !page.hasMore
return buffer.isEmpty ? nil : buffer.removeFirst()
}
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(client: client, userid: userid, pageSize: pageSize)
}
}
Now consumers stay tiny:
let stream = UserPostsStream(client: client, userid: id, pageSize: 30)
for try await video in stream.prefix(120) {
print(video.awemeId, video.playCount ?? 0)
}
Mobile networks drop packets. Wrap any single fetch in a retry loop that doubles the delay on each failure, capped at a sensible ceiling. Task.sleep is cancellation-aware, so the retries will stop the moment a caller cancels the parent task.
func withRetry<T>(maxAttempts: Int = 4,
baseDelay: Double = 0.5,
_ op: () async throws -> T) async throws -> T {
var attempt = 0
var delay = baseDelay
while true {
do { return try await op() }
catch {
attempt += 1
if attempt >= maxAttempts { throw error }
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
delay = min(delay * 2, 8.0)
}
}
}
let info = try await withRetry { try await client.userInfo(username: "tiktok") }
Suppose you have a list of creator handles and want fresh stats for each, but you also do not want to flood the API. A TaskGroup with a semaphore-style throttle keeps in-flight work bounded.
actor Throttle {
private var inFlight = 0
private let limit: Int
init(limit: Int) { self.limit = limit }
func acquire() async {
while inFlight >= limit { await Task.yield() }
inFlight += 1
}
func release() { inFlight -= 1 }
}
func bulkStats(usernames: [String],
client: TikLiveClient) async -> [String: UserInfo] {
let throttle = Throttle(limit: 4)
return await withTaskGroup(of: (String, UserInfo?).self) { group in
for name in usernames {
group.addTask {
await throttle.acquire()
defer { Task { await throttle.release() } }
let info = try? await withRetry {
try await client.userInfo(username: name)
}
return (name, info)
}
}
var out: [String: UserInfo] = [:]
for await (name, info) in group {
if let info { out[name] = info }
}
return out
}
}
Four concurrent fetches is a friendly default. Tune up only after watching your usage chart in your profile.
Glue the pieces together in an @Observable view model and a SwiftUI List. The view kicks off the lookup on appear and streams posts as they arrive.
import SwiftUI
@Observable
final class CreatorVM {
var info: UserInfo?
var videos: [Video] = []
var error: String?
private let client = TikLiveClient()
func load(username: String) async {
do {
let id = try await withRetry { try await client.resolveUserId(username: username) }
self.info = try await withRetry { try await client.userInfo(username: username) }
let stream = UserPostsStream(client: client, userid: id, pageSize: 20)
for try await v in stream.prefix(30) { videos.append(v) }
} catch { self.error = String(describing: error) }
}
}
struct CreatorView: View {
let username: String
@State private var vm = CreatorVM()
var body: some View {
List {
if let user = vm.info?.user, let stats = vm.info?.stats {
Section {
Text(user.nickname).font(.headline)
Text("@\(user.uniqueId)").foregroundStyle(.secondary)
Text("Followers: \(stats.followerCount)")
Text("Hearts: \(stats.heartCount)")
}
}
Section("Recent posts") {
ForEach(vm.videos, id: \.awemeId) { v in
HStack {
Text(v.awemeId).font(.caption.monospaced())
Spacer()
Text("\(v.playCount ?? 0) plays")
}
}
}
}
.task { await vm.load(username: username) }
}
}
The Keychain prevents the key from leaking to other apps, but it does not stop the user from extracting it from a jailbroken device once your app writes it there. More importantly, you do not want every install of your app holding your master credit pool. Two production-grade patterns:
X-Api-Key and forwards requests after authenticating the iOS caller. Your app talks to your proxy, the proxy talks to api.tikliveapi.com. This is exactly how the TikLiveAPI Playground protects its key.App Review also pays attention to TikTok-branded UI. Keep your app's value clearly distinct from the official TikTok client, do not impersonate TikTok login, and document in your review notes that your app reads only publicly available profile data through a third-party API.
No. The 37 endpoints cover users, posts, music, challenges, search, playlists, downloads, collections, regions, and ads. For anything related to a creator's live content, use /post-detail/, /user-posts/, or /user-stories/.
/post-detail/ returns flat snake_case fields including play (no-watermark SD), wmplay (watermarked), and hdplay (HD no-watermark). URL-encode the input url parameter.
Apps that read public TikTok profile data through a third-party API are routinely approved. Make sure you do not embed login flows that look like TikTok's, do not store user credentials, and disclose the data source in your privacy policy. Use a backend proxy so your key never ships in the binary.
Catch TikLiveError.http(429) specifically, back off longer than the generic retry, and surface a friendly toast to the user. Watch your daily counter on your profile, which is fed by the same per-account stats pipeline that aggregates real-time usage.
Use the in-browser Playground to call any endpoint with your live key, copy the JSON response, and paste it into Xcode's JSONDecoder as a fixture. If anything breaks during integration, the support team answers within a business day, and there are more migration notes on the blog.
Ready to put what you read into code? Try our endpoints live or grab the full reference.