How to Scrape TikTok User Data with Swift on iOS

Published on May 29, 2026

Why Swift Is a Natural Fit for TikTok Data on iOS

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.

Prerequisites

  • Xcode 15 or later targeting iOS 17+
  • Swift 5.9+ for native async/await, AsyncSequence, and typed throws
  • A TikLiveAPI account and API key (sign up, then grab the key from your profile; see pricing for credit packs)
  • Comfort with URLSession, Codable, and SwiftUI

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

Step 1: Store the API Key in the Keychain

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.

Step 2: Define Codable Structs That Match Each Response Shape

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

Step 3: Fetch User Info with URLSession and async/await

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

Step 4: Resolve the Numeric userid

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.

Step 5: Pull User Posts with Cursor Pagination

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.

Step 6: Wrap Pagination in an AsyncSequence

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

Step 7: Retry With Exponential Backoff

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

Step 8: Concurrency With TaskGroup and a Bounded Throttle

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.

Step 9: A SwiftUI Creator Profile View

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

App Store Concerns: Do Not Ship the API Key in the Binary

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:

  • Per-user keys. Have each end user sign up at tikliveapi.com/register and paste their own key into your app. They own the credits; you own zero risk.
  • Tiny backend proxy. Stand up a minimal server (a single Cloudflare Worker is enough) that holds your 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.

FAQ

Does TikLiveAPI provide TikTok Live (livestream) endpoints?

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

How do I get HD no-watermark video URLs?

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

Will App Review reject an app that uses a third-party TikTok API?

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.

How should I handle rate limits and quota in Swift?

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.

Where do I test endpoints before writing Swift code?

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.

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