C# 12 on .NET 8 is one of the most pleasant runtimes you can pick for a TikTok scraping workload. You get a first-class HttpClient, System.Text.Json source generation, LINQ over JSON, channels, IAsyncEnumerable<T> for paginated streams, and Parallel.ForEachAsync for safe concurrency, all without any external dependency. Pair that with TikLiveAPI's 37 REST endpoints and you have a production-grade scraper that fits comfortably in a Console app, an ASP.NET Core service, or a long-running Worker.
This tutorial walks through fetching a user profile, resolving their numeric ID, paging through their posts, retrying transient failures with Polly, parallelising lookups, and writing a daily follower snapshot to CSV. Every endpoint shown is documented at tikliveapi.com/documentation/, and you can experiment live in the Playground before writing a line of C#.
Polly (resilience), CsvHelper (CSV writing), Microsoft.Extensions.Http (typed clients). The standard library covers most of the work.Spin up a project:
dotnet new console -n TikTokScraper
cd TikTokScraper
dotnet add package Polly
dotnet add package CsvHelper
Never hardcode the key. Use IConfiguration in ASP.NET Core, or Environment.GetEnvironmentVariable in a Console app. The TikLiveAPI auth header is X-Api-Key, and the base URL is https://api.tikliveapi.com.
namespace TikTokScraper;
public static class ApiConfig
{
public const string BaseUrl = "https://api.tikliveapi.com";
public static string ApiKey =>
Environment.GetEnvironmentVariable("TIKLIVE_API_KEY")
?? throw new InvalidOperationException(
"Set the TIKLIVE_API_KEY environment variable.");
}
Export it before running:
export TIKLIVE_API_KEY="your_key_here"
dotnet run
The API mixes two conventions you must respect. Endpoints under /userinfo-by-username/ return nested objects in camelCase (user{} and stats{}), while video-oriented endpoints such as /user-posts/, /post-detail/, and /music-info/ return flat snake_case fields. Map each with [JsonPropertyName].
using System.Text.Json.Serialization;
namespace TikTokScraper.Models;
// /userinfo-by-username/ - nested, camelCase
public sealed record UserInfoResponse(
[property: JsonPropertyName("user")] UserBlock User,
[property: JsonPropertyName("stats")] StatsBlock Stats);
public sealed record UserBlock(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("uniqueId")] string UniqueId,
[property: JsonPropertyName("nickname")] string Nickname,
[property: JsonPropertyName("signature")] string? Signature,
[property: JsonPropertyName("verified")] bool Verified,
[property: JsonPropertyName("avatarLarger")] string? AvatarLarger);
public sealed record StatsBlock(
[property: JsonPropertyName("followerCount")] long FollowerCount,
[property: JsonPropertyName("followingCount")] long FollowingCount,
[property: JsonPropertyName("heartCount")] long HeartCount,
[property: JsonPropertyName("videoCount")] long VideoCount,
[property: JsonPropertyName("diggCount")] long DiggCount);
// /userid/ - flat, single string field
public sealed record UserIdResponse(
[property: JsonPropertyName("id")] string Id);
Records give you immutability, value equality, and concise primary constructors. Mark them sealed to help the JIT and the source generator.
Reuse a single HttpClient for the lifetime of your app. Wrap it in a small typed client so consumers don't have to remember headers or query string assembly.
using System.Net.Http.Json;
using System.Text.Json;
using TikTokScraper.Models;
namespace TikTokScraper;
public sealed class TikLiveClient(HttpClient http)
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true
};
public async Task<UserInfoResponse> GetUserInfoAsync(
string username, CancellationToken ct = default)
{
var url = $"/userinfo-by-username/?username={Uri.EscapeDataString(username)}";
return await http.GetFromJsonAsync<UserInfoResponse>(url, JsonOpts, ct)
?? throw new InvalidOperationException("Empty response from user info.");
}
}
Construct it once at startup:
using var http = new HttpClient { BaseAddress = new Uri(ApiConfig.BaseUrl) };
http.DefaultRequestHeaders.Add("X-Api-Key", ApiConfig.ApiKey);
var client = new TikLiveClient(http);
var info = await client.GetUserInfoAsync("tiktok");
Console.WriteLine($"{info.User.Nickname} - {info.Stats.FollowerCount:N0} followers");
Most post and follower endpoints want the numeric userid, not the handle. The /userid/ endpoint returns a flat object with a single id string. Add another method to the typed client:
public async Task<string> GetUserIdAsync(
string username, CancellationToken ct = default)
{
var url = $"/userid/?username={Uri.EscapeDataString(username)}";
var resp = await http.GetFromJsonAsync<UserIdResponse>(url, JsonOpts, ct);
return resp?.Id ?? throw new InvalidOperationException("Username not found.");
}
You can also pull the ID directly from UserInfoResponse.User.Id when you already have the profile, saving one credit.
The /user-posts/ endpoint returns three top-level keys: videos, cursor (string ms timestamp), and hasMore (camelCase boolean). Video items are flat snake_case: aweme_id, play, wmplay, play_count, digg_count, create_time, and a nested author{}.
public sealed record UserPostsResponse(
[property: JsonPropertyName("videos")] List<VideoItem> Videos,
[property: JsonPropertyName("cursor")] string Cursor,
[property: JsonPropertyName("hasMore")] bool HasMore);
public sealed record VideoItem(
[property: JsonPropertyName("aweme_id")] string AwemeId,
[property: JsonPropertyName("title")] string? Title,
[property: JsonPropertyName("play")] string? Play,
[property: JsonPropertyName("wmplay")] string? Wmplay,
[property: JsonPropertyName("play_count")] long PlayCount,
[property: JsonPropertyName("digg_count")] long DiggCount,
[property: JsonPropertyName("comment_count")] long CommentCount,
[property: JsonPropertyName("share_count")] long ShareCount,
[property: JsonPropertyName("create_time")] long CreateTime,
[property: JsonPropertyName("author")] VideoAuthor Author);
public sealed record VideoAuthor(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("unique_id")] string UniqueId,
[property: JsonPropertyName("nickname")] string Nickname);
And the fetch method:
public async Task<UserPostsResponse> GetUserPostsAsync(
string userId, int count = 20, string cursor = "0",
CancellationToken ct = default)
{
var url = $"/user-posts/?userid={userId}&count={count}&cursor={cursor}";
return await http.GetFromJsonAsync<UserPostsResponse>(url, JsonOpts, ct)
?? throw new InvalidOperationException("Empty posts response.");
}
Pagination is endpoint-specific. /user-posts/ uses a string cursor plus hasMore. The follower endpoints (/user-followers/ and /user-following/) use a time parameter in unix seconds instead. Wrap the pagination in IAsyncEnumerable<T> so callers can compose it with LINQ or just await foreach.
public async IAsyncEnumerable<VideoItem> StreamUserPostsAsync(
string userId, int pageSize = 20,
[System.Runtime.CompilerServices.EnumeratorCancellation]
CancellationToken ct = default)
{
var cursor = "0";
while (!ct.IsCancellationRequested)
{
var page = await GetUserPostsAsync(userId, pageSize, cursor, ct);
foreach (var v in page.Videos) yield return v;
if (!page.HasMore || string.IsNullOrEmpty(page.Cursor)) yield break;
cursor = page.Cursor;
}
}
The follower stream uses time instead. Note the top-level key on the following endpoint is the plural followings, not following.
public sealed record FollowersPage(
[property: JsonPropertyName("followers")] List<FollowerUser> Followers,
[property: JsonPropertyName("total")] long Total,
[property: JsonPropertyName("time")] long Time,
[property: JsonPropertyName("hasMore")] bool HasMore);
public sealed record FollowerUser(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("uniqueId")] string UniqueId,
[property: JsonPropertyName("region")] string? Region);
public async IAsyncEnumerable<FollowerUser> StreamFollowersAsync(
string userId, int pageSize = 50,
[System.Runtime.CompilerServices.EnumeratorCancellation]
CancellationToken ct = default)
{
long time = 0;
while (!ct.IsCancellationRequested)
{
var url = $"/user-followers/?userid={userId}&count={pageSize}&time={time}";
var page = await http.GetFromJsonAsync<FollowersPage>(url, JsonOpts, ct);
if (page is null) yield break;
foreach (var f in page.Followers) yield return f;
if (!page.HasMore || page.Time == 0) yield break;
time = page.Time;
}
}
Consume it like any other async stream:
await foreach (var post in client.StreamUserPostsAsync(userId).Take(100))
Console.WriteLine($"{post.AwemeId} - {post.PlayCount:N0} plays");
TikLiveAPI's standard limit is 200 requests per minute. Real networks also hiccup. Wrap the HTTP layer in a Polly v8 pipeline that retries transient 5xx and 429 responses with exponential backoff plus a circuit breaker.
using Polly;
using Polly.Retry;
using Polly.CircuitBreaker;
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(r => (int)r.StatusCode >= 500 || (int)r.StatusCode == 429)
.Handle<HttpRequestException>(),
MaxRetryAttempts = 4,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromMilliseconds(500),
UseJitter = true
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 8,
BreakDuration = TimeSpan.FromSeconds(15)
})
.Build();
var response = await pipeline.ExecuteAsync(
async ct => await http.GetAsync("/userid/?username=tiktok", ct));
If you would rather not add a dependency, a hand-rolled retry is roughly twenty lines:
static async Task<T> WithRetryAsync<T>(
Func<Task<T>> action, int maxAttempts = 4)
{
var delay = TimeSpan.FromMilliseconds(500);
for (var attempt = 1; ; attempt++)
{
try { return await action(); }
catch (HttpRequestException) when (attempt < maxAttempts)
{
await Task.Delay(delay);
delay *= 2;
}
}
}
Need to hydrate posts for a hundred usernames? Parallel.ForEachAsync is the modern primitive. It honours cancellation, surfaces exceptions cleanly, and lets you cap concurrency through ParallelOptions.MaxDegreeOfParallelism. Combine it with a SemaphoreSlim when you also need to throttle a sub-resource (database writes, file handles).
var usernames = new[] { "tiktok", "khaby.lame", "charlidamelio", "bellapoarch" };
var writeLock = new SemaphoreSlim(1, 1);
var results = new List<(string Name, long Followers)>();
await Parallel.ForEachAsync(
usernames,
new ParallelOptions { MaxDegreeOfParallelism = 4 },
async (name, ct) =>
{
var info = await client.GetUserInfoAsync(name, ct);
await writeLock.WaitAsync(ct);
try { results.Add((name, info.Stats.FollowerCount)); }
finally { writeLock.Release(); }
});
foreach (var r in results.OrderByDescending(x => x.Followers))
Console.WriteLine($"{r.Name,-20} {r.Followers,15:N0}");
Keep MaxDegreeOfParallelism well below your per-minute quota. Four to eight is usually the sweet spot.
Put it all together. The next snippet reads a list of handles, fetches each profile in parallel, and appends one row per handle to followers.csv with the current UTC date. Drop it in a Worker Service and schedule it with cron, Hangfire, or Quartz.
using System.Globalization;
using CsvHelper;
public sealed record FollowerSnapshot(
string Date, string Username, string UserId,
long Followers, long Following, long Videos, long Hearts);
public static async Task TrackAsync(
TikLiveClient client, IReadOnlyList<string> handles, string csvPath)
{
var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
var rows = new List<FollowerSnapshot>();
var gate = new SemaphoreSlim(1, 1);
await Parallel.ForEachAsync(
handles,
new ParallelOptions { MaxDegreeOfParallelism = 6 },
async (handle, ct) =>
{
var info = await client.GetUserInfoAsync(handle, ct);
var row = new FollowerSnapshot(
today, handle, info.User.Id,
info.Stats.FollowerCount, info.Stats.FollowingCount,
info.Stats.VideoCount, info.Stats.HeartCount);
await gate.WaitAsync(ct);
try { rows.Add(row); } finally { gate.Release(); }
});
var append = File.Exists(csvPath);
await using var writer = new StreamWriter(csvPath, append: append);
await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
if (!append) csv.WriteHeader<FollowerSnapshot>();
await csv.NextRecordAsync();
await csv.WriteRecordsAsync(rows);
}
If you would rather avoid CsvHelper, a stdlib StreamWriter works just as well:
await using var w = new StreamWriter(csvPath, append: true);
foreach (var r in rows)
await w.WriteLineAsync($"{r.Date},{r.Username},{r.UserId},{r.Followers}");
In an ASP.NET Core or Worker Service, prefer IHttpClientFactory with a typed client. It manages socket lifetime, supports DI, and integrates with the Polly v8 resilience handler shipped in Microsoft.Extensions.Http.Resilience. Register it once at startup, then inject TikLiveClient anywhere.
builder.Services.AddHttpClient<TikLiveClient>(c =>
{
c.BaseAddress = new Uri(ApiConfig.BaseUrl);
c.DefaultRequestHeaders.Add(
"X-Api-Key", builder.Configuration["TikLive:ApiKey"]!);
})
.AddStandardResilienceHandler();
Everything you learned here generalises to the rest of the API. Swap the endpoint and the record types and you can pull comments (top-level comments[] with an id field, plus cursor and hasMore), hashtag insights via /challenge-info-name/ (the response field is cha_name, not name), music metadata, no-watermark download URLs, and full-text search across users, videos, and challenges. Browse the full catalogue in the documentation, try queries live in the playground, or reach out via contact if you need a higher quota.
No. TikLiveAPI does not require your TikTok password or session cookies. Authentication is a per-account X-Api-Key header that you generate on your profile page.
Standard plans allow 200 requests per minute. Cap MaxDegreeOfParallelism at six to eight, add the Polly retry pipeline shown above, and you will stay comfortably under it. Higher limits are available on request.
The follower endpoints use a time parameter (unix seconds), not a cursor string. Pass time=0 on the first call, then feed the time value from the response into the next call until hasMore is false. The following endpoint exposes the list under the plural key followings.
Yes, but you will lose primary constructors, collection expressions, and the source-generated JSON serializer. HttpClient and Newtonsoft.Json cover the basics, and the same endpoint contracts apply.
One request equals one credit. There are no subscriptions, no overage fees, and credits never expire. New verified accounts receive 100 free credits to test. See the full breakdown on the pricing page, and read more developer guides on the blog.
Ready to put what you read into code? Try our endpoints live or grab the full reference.