If your stack already runs on the JVM, reaching for a separate Python or Node service just to pull TikTok data is more friction than it is worth. Java is still the workhorse of backend systems for a reason: type safety catches schema drift at compile time, the threading model handles fan-out scraping without breaking a sweat, and the ecosystem around Spring Boot, Quarkus, and Micronaut means whatever you build slots cleanly into your existing observability, security, and deployment pipelines.
This tutorial walks through scraping TikTok user data with TikLiveAPI, a managed REST API that handles the messy parts of TikTok scraping (private endpoints, signatures, rotating IPs) so you can focus on the data. We will use the built-in java.net.http.HttpClient (no Apache HttpClient, no OkHttp required) plus Jackson for JSON binding. Everything runs on Java 17+ with modern idioms: record classes, sealed types, var, try-with-resources, and Optional.
By the end you will have a production-grade follower tracker that fetches user info, paginates posts, handles retries with exponential backoff, and scales out via CompletableFuture. If you would rather poke at the endpoints in a browser first, the playground lets you fire requests against a live API key without writing any code.
com.fasterxml.jackson.core:jackson-databind:2.17.0.https://api.tikliveapi.com. Authentication header on every request: X-Api-Key: YOUR_API_KEY.That is the entire toolchain. No CLI scrapers, no headless browser, no proxy pool to babysit. Pricing is pay-as-you-go: one request equals one credit, and credits never expire.
Never hardcode the key in source. The two idiomatic Java sources are environment variables and JVM system properties. A tiny helper covers both, with Optional doing the heavy lifting:
public final class ApiKey {
private ApiKey() {}
public static String resolve() {
return Optional.ofNullable(System.getenv("TIKLIVE_API_KEY"))
.or(() -> Optional.ofNullable(System.getProperty("tiklive.api.key")))
.orElseThrow(() -> new IllegalStateException(
"Set TIKLIVE_API_KEY env var or -Dtiklive.api.key=..."));
}
}
Export it once per shell: export TIKLIVE_API_KEY=sk_live_... and the rest of the code reads it transparently.
This is where Java's type system earns its keep. The /userinfo-by-username/ endpoint returns a nested object with a user block and a stats block, both camelCase. Records map onto that shape one to one:
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record UserInfo(User user, Stats stats) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record User(
String id,
String uniqueId,
String nickname,
String avatarLarger,
String signature,
boolean verified,
String secUid
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Stats(
long followingCount,
long followerCount,
long heartCount,
long videoCount,
long diggCount
) {}
}
The @JsonIgnoreProperties(ignoreUnknown = true) annotation is crucial: TikTok schemas evolve, and you do not want a new field crashing your parser. Because the JSON keys are already camelCase, Jackson maps them directly to record components, no @JsonProperty needed.
For the snake_case endpoints (more on that in Step 5), you have two options: annotate each field with @JsonProperty("snake_name"), or configure the ObjectMapper with PropertyNamingStrategies.SNAKE_CASE globally. The second scales better.
One shared HttpClient instance for the whole app (it is thread-safe and pools connections), one shared ObjectMapper, and a thin client class:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import com.fasterxml.jackson.databind.ObjectMapper;
public class TikLiveClient {
private static final String BASE = "https://api.tikliveapi.com";
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.version(HttpClient.Version.HTTP_2)
.build();
private final ObjectMapper mapper = new ObjectMapper();
private final String apiKey = ApiKey.resolve();
public UserInfo getUserInfo(String username) throws Exception {
var uri = URI.create(BASE + "/userinfo-by-username/?username="
+ URLEncoder.encode(username, StandardCharsets.UTF_8));
var req = HttpRequest.newBuilder(uri)
.header("X-Api-Key", apiKey)
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(15))
.GET()
.build();
var res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) {
throw new IOException("HTTP " + res.statusCode() + ": " + res.body());
}
return mapper.readValue(res.body(), UserInfo.class);
}
}
Call it: var info = client.getUserInfo("charlidamelio"); and you get back a typed record with all five stats counters. The full schema lives in the user-info documentation.
Most pagination-friendly endpoints (posts, followers, following) want the numeric userid, not the handle. The /userid/ endpoint takes the username and returns a flat object with a single id field:
public record UserIdResponse(String id) {}
public String resolveUserId(String username) throws Exception {
var uri = URI.create(BASE + "/userid/?username="
+ URLEncoder.encode(username, StandardCharsets.UTF_8));
var req = HttpRequest.newBuilder(uri)
.header("X-Api-Key", apiKey).GET().build();
var res = http.send(req, HttpResponse.BodyHandlers.ofString());
return mapper.readValue(res.body(), UserIdResponse.class).id();
}
Yes, that is the entire mapper for a flat single-key response. Records make trivial DTOs disappear.
The /user-posts/ endpoint is where Java's type system pays the biggest dividend, and also where naming conventions get spicy. The top-level keys are mixed: videos (array, snake_case items inside), cursor (string ms timestamp), and hasMore (camelCase boolean). Each video item is flat snake_case: aweme_id, play_count, digg_count, comment_count, create_time, and so on.
The cleanest way is to annotate individual records and let Jackson handle the rest:
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public record PostsPage(
List<Video> videos,
String cursor,
boolean hasMore
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Video(
@JsonProperty("aweme_id") String awemeId,
@JsonProperty("video_id") String videoId,
String title,
String region,
@JsonProperty("play_count") long playCount,
@JsonProperty("digg_count") long diggCount,
@JsonProperty("comment_count") long commentCount,
@JsonProperty("share_count") long shareCount,
@JsonProperty("download_count") long downloadCount,
@JsonProperty("create_time") long createTime,
Author author
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Author(
String id,
@JsonProperty("unique_id") String uniqueId,
String nickname
) {}
}
Notice hasMore is plain camelCase (no annotation), but every video field uses @JsonProperty. That matches the truth on the wire. The fetch method:
public PostsPage getPosts(String userId, int count, String cursor) throws Exception {
var sb = new StringBuilder(BASE + "/user-posts/?userid=" + userId + "&count=" + count);
if (cursor != null && !cursor.isEmpty()) sb.append("&cursor=").append(cursor);
var req = HttpRequest.newBuilder(URI.create(sb.toString()))
.header("X-Api-Key", apiKey).GET().build();
var res = http.send(req, HttpResponse.BodyHandlers.ofString());
return mapper.readValue(res.body(), PostsPage.class);
}
Almost every list-style endpoint uses cursor pagination: pass the previous response's cursor as the next request's cursor parameter, and stop when hasMore is false. An Iterator over pages keeps caller code clean:
public Stream<PostsPage.Video> allPosts(String userId, int pageSize) {
var iter = new Iterator<PostsPage>() {
String cursor = null;
boolean done = false;
public boolean hasNext() { return !done; }
public PostsPage next() {
try {
var page = getPosts(userId, pageSize, cursor);
cursor = page.cursor();
done = !page.hasMore();
return page;
} catch (Exception e) { throw new RuntimeException(e); }
}
};
return StreamSupport
.stream(Spliterators.spliteratorUnknownSize(iter, 0), false)
.flatMap(p -> p.videos().stream());
}
Now client.allPosts(userId, 30).limit(500).toList() gets you the latest 500 videos with zero pagination ceremony.
One subtlety: /user-followers/ and /user-following/ use time (unix seconds) instead of cursor, and the response key for following is the plural followings. Follower endpoints also return hasMore in camelCase. See followers docs and following docs for the exact shapes.
Network blips, transient 429s, and brief 5xxs happen. A handcrafted retry wrapper keeps the dependency list short:
public <T> T withRetry(int maxAttempts, ThrowingSupplier<T> op) throws Exception {
long delay = 500;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return op.get();
} catch (Exception e) {
if (attempt == maxAttempts) throw e;
long jitter = ThreadLocalRandom.current().nextLong(100, 400);
Thread.sleep(delay + jitter);
delay = Math.min(delay * 2, 8000);
}
}
throw new IllegalStateException("unreachable");
}
@FunctionalInterface
public interface ThrowingSupplier<T> { T get() throws Exception; }
Usage: var info = withRetry(4, () -> getUserInfo("nasa"));. Standard plans allow 200 requests per minute, so back off generously rather than hammering on every failure.
When you need to fetch info for hundreds of usernames, a bounded thread pool plus CompletableFuture is the idiomatic Java way. Do not unbounded-parallelize: TikLiveAPI's standard rate limit caps you at roughly 3 requests per second sustained.
var pool = Executors.newFixedThreadPool(8);
try {
var futures = usernames.stream()
.map(name -> CompletableFuture.supplyAsync(() -> {
try { return withRetry(3, () -> getUserInfo(name)); }
catch (Exception e) { throw new CompletionException(e); }
}, pool))
.toList();
var results = futures.stream()
.map(CompletableFuture::join)
.toList();
} finally {
pool.shutdown();
}
On Java 21, swap Executors.newFixedThreadPool(8) for Executors.newVirtualThreadPerTaskExecutor() and you can blow through a thousand handles without worrying about pool sizing.
Putting it all together: a scheduled job that records the follower count of a list of creators once per day. Java NIO handles the file I/O, no OpenCSV needed for this simple shape.
public void recordSnapshot(List<String> usernames, Path csv) throws Exception {
var today = LocalDate.now().toString();
var lines = new ArrayList<String>();
for (var name : usernames) {
var info = withRetry(3, () -> getUserInfo(name));
lines.add(String.join(",",
today,
name,
String.valueOf(info.stats().followerCount()),
String.valueOf(info.stats().heartCount()),
String.valueOf(info.stats().videoCount())));
}
boolean newFile = !Files.exists(csv);
try (var writer = Files.newBufferedWriter(csv,
StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
if (newFile) writer.write("date,username,followers,hearts,videos\n");
for (var row : lines) { writer.write(row); writer.newLine(); }
}
}
Hook this into a Quartz job or a Spring @Scheduled(cron = "0 0 6 * * *") method and you have a self-contained follower analytics pipeline running daily, costing one credit per username per run.
Spring Boot devs have two great options. WebClient (from spring-webflux) gives you reactive non-blocking calls with built-in retry via Retry.backoff() and great backpressure semantics, ideal if you are already on Project Reactor. RestClient (Spring Framework 6.1+) is the synchronous successor to RestTemplate with a fluent API that is closer to HttpClient but with Spring's converters and interceptors. Register the API key in a RestClient.Builder bean via defaultHeader("X-Api-Key", apiKey) and inject the client anywhere you need TikTok data. Both bind Jackson records out of the box.
TikLiveAPI exposes 37 endpoints in total. Once you have user scraping working, the same client pattern unlocks the rest: post detail for a single video's metadata, post comments for engagement analysis (comment id field is id, not cid, by the way), and video downloads for no-watermark MP4 URLs. The full surface area is documented at documentation, and you can browse the blog for more language guides.
No. TikLiveAPI never asks for a TikTok username or password. You authenticate to TikLiveAPI with your own per-account API key sent in the X-Api-Key header.
200 requests per minute, which works out to roughly 3 per second sustained. The cap can be raised on request, so reach out via contact if your workload needs more headroom.
TikLiveAPI mirrors TikTok's upstream naming where possible. Video items and most flat endpoints use snake_case (play_count, create_time), while user-info nested blocks and pagination flags like hasMore are camelCase. Jackson handles both via @JsonProperty annotations or a global naming strategy.
Those two endpoints use a time parameter (unix seconds) instead of cursor. Pass the time value from the previous response as the next request's time parameter, and watch hasMore to know when to stop. The response key for the following endpoint is the plural followings.
No. Credits are pay-as-you-go and never expire. Unused credits from a package are refundable if none of them have been consumed. Full terms live on the pricing page.
Ready to put what you read into code? Try our endpoints live or grab the full reference.