Laravel ships with the exact primitives a TikTok-aware application needs: an HTTP client built on Guzzle, Eloquent for modelling creator snapshots, queues for fan-out polling, and the scheduler for nightly ingestion. TikLiveAPI, in turn, hands you 37 ready-to-call TikTok endpoints behind a single X-Api-Key header, so you skip the entire scraping, rotation, and proxy layer.
This guide walks through wiring TikLiveAPI into a Laravel 11 application end to end: a typed client, persistence layer, queued snapshots, scheduled ingestion, API resources, retries, caching, an admin sketch, and tests. By the end you will have a creator dashboard that captures a daily snapshot per creator, exposes a paginated posts endpoint, and gracefully degrades when upstream is slow.
One honest note up front: TikLiveAPI does not expose a dedicated TikTok Live (livestream) endpoint. Live-specific stream telemetry is not available. What you can access about Live content that a creator publishes to their feed is reachable through the regular post and profile endpoints such as /post-detail/, /user-posts/, and /user-stories/. The patterns in this guide apply to all of those.
Add your key to .env:
TIKLIVEAPI_KEY=your_key_here
TIKLIVEAPI_BASE_URL=https://api.tikliveapi.com
Register it in config/services.php so the value flows through Laravel's config cache:
'tikliveapi' => [
'key' => env('TIKLIVEAPI_KEY'),
'base_url' => env('TIKLIVEAPI_BASE_URL', 'https://api.tikliveapi.com'),
'timeout' => 15,
],
Never inject the key directly inside controllers. Always pull from config('services.tikliveapi.key') so it is overridable in tests and CI.
Create app/Services/TikLiveApi.php. This is the single class that talks to the upstream API. Everything else in your app calls methods on this object.
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class TikLiveApi
{
public function __construct(
private readonly string $key,
private readonly string $baseUrl,
private readonly int $timeout = 15,
) {}
private function http(): PendingRequest
{
return Http::baseUrl($this->baseUrl)
->withHeaders(['X-Api-Key' => $this->key])
->acceptJson()
->timeout($this->timeout)
->retry(3, 250, throw: false);
}
public function userId(string $username): ?string
{
$res = $this->http()->get('/userid/', ['username' => $username]);
return $res->successful() ? ($res->json('id') ?? null) : null;
}
public function userInfo(string $username): ?array
{
$res = $this->http()->get('/userinfo-by-username/', ['username' => $username]);
return $res->successful() ? $res->json() : null;
}
public function userInfoById(string $userid): ?array
{
$res = $this->http()->get('/userinfo-by-id/', ['userid' => $userid]);
return $res->successful() ? $res->json() : null;
}
public function userPosts(string $userid, int $count = 20, string $cursor = '0'): array
{
return $this->http()->get('/user-posts/', [
'userid' => $userid,
'count' => $count,
'cursor' => $cursor,
])->json() ?? ['videos' => [], 'cursor' => '0', 'hasMore' => false];
}
public function userFollowers(string $userid, int $count = 50, int $time = 0): array
{
return $this->http()->get('/user-followers/', [
'userid' => $userid,
'count' => $count,
'time' => $time,
])->json() ?? ['followers' => [], 'total' => 0, 'time' => 0, 'hasMore' => false];
}
}
Two things worth highlighting against the real response shapes. The followers endpoint paginates by a time integer rather than a cursor string, so the default fetcher uses time => 0 on the first page and then echoes back whatever the response carries. The userInfo response is nested: counters live under stats, profile fields under user. Do not treat it as a flat payload.
Bind the class as a singleton in app/Providers/AppServiceProvider.php:
$this->app->singleton(TikLiveApi::class, function () {
return new TikLiveApi(
key: config('services.tikliveapi.key'),
baseUrl: config('services.tikliveapi.base_url'),
timeout: config('services.tikliveapi.timeout'),
);
});
Three tables cover most analytics use cases. A creator row per tracked TikTok account, a snapshot row per day per creator, and a post row per video we have observed.
// database/migrations/2026_05_29_000001_create_tiktok_creators_table.php
Schema::create('tiktok_creators', function (Blueprint $t) {
$t->id();
$t->string('username')->unique();
$t->string('tiktok_user_id')->nullable()->index();
$t->string('nickname')->nullable();
$t->text('avatar')->nullable();
$t->boolean('verified')->default(false);
$t->timestamp('last_synced_at')->nullable();
$t->timestamps();
});
// database/migrations/2026_05_29_000002_create_tiktok_snapshots_table.php
Schema::create('tiktok_snapshots', function (Blueprint $t) {
$t->id();
$t->foreignId('creator_id')->constrained('tiktok_creators')->cascadeOnDelete();
$t->date('captured_on');
$t->unsignedBigInteger('follower_count')->default(0);
$t->unsignedBigInteger('following_count')->default(0);
$t->unsignedBigInteger('heart_count')->default(0);
$t->unsignedBigInteger('video_count')->default(0);
$t->timestamps();
$t->unique(['creator_id', 'captured_on']);
});
// database/migrations/2026_05_29_000003_create_tiktok_posts_table.php
Schema::create('tiktok_posts', function (Blueprint $t) {
$t->id();
$t->foreignId('creator_id')->constrained('tiktok_creators')->cascadeOnDelete();
$t->string('aweme_id')->unique();
$t->unsignedBigInteger('play_count')->default(0);
$t->unsignedBigInteger('digg_count')->default(0);
$t->text('play_url')->nullable();
$t->text('hd_play_url')->nullable();
$t->timestamp('observed_at');
$t->timestamps();
});
The Eloquent models are thin and conventional:
class TikTokCreator extends Model
{
protected $fillable = ['username', 'tiktok_user_id', 'nickname', 'avatar', 'verified', 'last_synced_at'];
protected $casts = ['verified' => 'bool', 'last_synced_at' => 'datetime'];
public function snapshots() { return $this->hasMany(TikTokSnapshot::class, 'creator_id'); }
public function posts() { return $this->hasMany(TikTokPost::class, 'creator_id'); }
}
The service layer is where you translate the upstream nested shape into your flat database columns. Keeping this conversion in one place means downstream code never has to know the API exists.
namespace App\Services;
use App\Models\TikTokCreator;
use App\Models\TikTokSnapshot;
use Carbon\Carbon;
class SnapshotService
{
public function __construct(private readonly TikLiveApi $api) {}
public function capture(TikTokCreator $creator): ?TikTokSnapshot
{
$payload = $this->api->userInfo($creator->username);
if (!$payload || !isset($payload['stats'])) {
return null;
}
$user = $payload['user'] ?? [];
$stats = $payload['stats'] ?? [];
$creator->forceFill([
'tiktok_user_id' => $user['id'] ?? $creator->tiktok_user_id,
'nickname' => $user['nickname'] ?? $creator->nickname,
'avatar' => $user['avatarMedium']?? $creator->avatar,
'verified' => (bool) ($user['verified'] ?? false),
'last_synced_at' => now(),
])->save();
return TikTokSnapshot::updateOrCreate(
['creator_id' => $creator->id, 'captured_on' => Carbon::today()],
[
'follower_count' => $stats['followerCount'] ?? 0,
'following_count' => $stats['followingCount'] ?? 0,
'heart_count' => $stats['heartCount'] ?? 0,
'video_count' => $stats['videoCount'] ?? 0,
],
);
}
}
The nested camelCase keys come straight from the verified user-info response. If you later want to populate the same snapshot from info-by-id (handy when you already cached the numeric id), the shape is identical, so the service does not need a second code path.
Hitting upstream synchronously inside a scheduled command blocks the whole scheduler if any one creator is slow. Push each creator into its own queued job:
namespace App\Jobs;
use App\Models\TikTokCreator;
use App\Services\SnapshotService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ScheduleCreatorSnapshot implements ShouldQueue
{
use Dispatchable, Queueable, SerializesModels, InteractsWithQueue;
public int $tries = 3;
public int $backoff = 30;
public function __construct(public TikTokCreator $creator) {}
public function handle(SnapshotService $service): void
{
$service->capture($this->creator);
}
}
In Laravel 11 the scheduler lives in routes/console.php. Fan out one job per creator at 03:00 every day:
use App\Jobs\ScheduleCreatorSnapshot;
use App\Models\TikTokCreator;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
TikTokCreator::query()->each(
fn ($creator) => ScheduleCreatorSnapshot::dispatch($creator)
);
})->dailyAt('03:00')->name('tiktok:snapshot-fanout')->onOneServer();
An Artisan command for ad-hoc backfills is also useful. Generate one with php artisan make:command CaptureCreatorSnapshot and have it call ScheduleCreatorSnapshot::dispatchSync($creator) for a single username argument.
Expose the data to your frontend through an API resource so the JSON shape stays stable even when the database schema shifts.
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class TikTokCreatorResource extends JsonResource
{
public function toArray($request): array
{
$latest = $this->snapshots()->latest('captured_on')->first();
return [
'username' => $this->username,
'nickname' => $this->nickname,
'verified' => $this->verified,
'avatar' => $this->avatar,
'followers' => $latest?->follower_count,
'videos' => $latest?->video_count,
'hearts' => $latest?->heart_count,
'synced_at' => optional($this->last_synced_at)->toIso8601String(),
];
}
}
The client already retries three times with a 250 ms gap. For real production you want a circuit breaker so a misbehaving upstream does not torch your queue workers. A lightweight version uses the cache as a shared failure counter:
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use RuntimeException;
class CircuitBreaker
{
public function __construct(
private readonly string $key,
private readonly int $threshold = 5,
private readonly int $cooldown = 60,
) {}
public function guard(callable $call): mixed
{
if (Cache::get("cb:open:{$this->key}")) {
throw new RuntimeException("Circuit open for {$this->key}");
}
try {
$result = $call();
Cache::forget("cb:fail:{$this->key}");
return $result;
} catch (\Throwable $e) {
$fails = Cache::increment("cb:fail:{$this->key}");
if ($fails >= $this->threshold) {
Cache::put("cb:open:{$this->key}", true, $this->cooldown);
}
throw $e;
}
}
}
Wrap any client method call from inside the job: app(CircuitBreaker::class, ['key' => 'tikliveapi'])->guard(fn () => $service->capture($creator)).
During a single web request you might call userInfo() from a controller and again from a Blade component. Wrap repeated lookups with Cache::remember() using a short TTL so duplicate calls inside one request do not burn credits.
public function userInfoCached(string $username, int $ttl = 60): ?array
{
return Cache::remember(
"tla:userinfo:{$username}",
$ttl,
fn () => $this->userInfo($username),
);
}
One minute is a sensible default for browser-driven lookups. For background analytics jobs prefer no caching at all so each snapshot is honest about the moment it was captured.
Now wire a route that returns posts for any creator and lazily fetches from upstream if your database does not have them yet. The user-posts response paginates with a cursor string and a camelCase hasMore boolean, with the items themselves arriving as flat snake_case rows.
// routes/api.php
Route::get('/creators/{username}/posts', function (string $username, TikLiveApi $api) {
$creator = TikTokCreator::firstOrCreate(['username' => $username]);
if (!$creator->tiktok_user_id) {
$creator->update(['tiktok_user_id' => $api->userId($username)]);
}
$cursor = request('cursor', '0');
$payload = Cache::remember(
"tla:posts:{$creator->tiktok_user_id}:{$cursor}",
60,
fn () => $api->userPosts($creator->tiktok_user_id, 20, $cursor),
);
return response()->json([
'videos' => $payload['videos'] ?? [],
'cursor' => $payload['cursor'] ?? '0',
'hasMore' => $payload['hasMore'] ?? false,
]);
});
Individual videos returned here carry aweme_id, play, wmplay, play_count, and digg_count. If you need the HD no-watermark URL, call /post-detail/ with the video URL, which returns hdplay alongside play and wmplay in a flat snake_case payload.
A simple admin panel for browsing snapshots is one Filament resource away. Run php artisan make:filament-resource TikTokCreator --generate and expose a table with columns for username, nickname, and last_synced_at. Add a relation manager for snapshots with a chart widget that pulls follower_count over the last 30 days. Same idea with Nova: a Resource for the creator and a HasMany field for snapshots is enough to navigate your archive without writing Blade.
Drop in a quick action button labelled Capture now that dispatches ScheduleCreatorSnapshot::dispatchSync($record). Operators get an instant refresh when they need to debug a creator drift.
Mocking TikLiveAPI keeps tests fast and free. Use Laravel's built-in faker against the verified response shapes:
use Illuminate\Support\Facades\Http;
it('captures a snapshot from the upstream user-info response', function () {
Http::fake([
'api.tikliveapi.com/userinfo-by-username/*' => Http::response([
'user' => ['id' => '9876', 'nickname' => 'Ada', 'verified' => true],
'stats' => [
'followerCount' => 12000,
'followingCount' => 42,
'heartCount' => 380000,
'videoCount' => 88,
],
]),
]);
$creator = TikTokCreator::create(['username' => 'ada']);
app(SnapshotService::class)->capture($creator);
expect($creator->snapshots()->count())->toBe(1)
->and($creator->snapshots()->first()->follower_count)->toBe(12000);
});
For the followers endpoint, remember to fake a payload that uses time rather than cursor, and for following, that the top key is followings with a trailing s. For /post-comments/ tests, comment items use id, not cid.
Run Laravel Horizon for queue monitoring. Pin the snapshot queue to its own connection so it cannot starve your web request queue. A sensible Horizon supervisor allocates two processes for tiktok-snapshots with a balance of auto and a max of 10 concurrent jobs, which lines up with the average 750 ms upstream latency without overwhelming smaller plans.
Cost projection is simple because TikLiveAPI is pay-as-you-go: one request equals one credit. Snapshotting 200 creators every day consumes 6,000 credits per month for the userInfo call alone. Adding a daily user-posts fetch with one page each doubles it. Plan against your credit balance accordingly and instrument the SnapshotService to count successful captures so you can reconcile against the dashboard's daily counters.
For the cron itself, point your server's system cron at * * * * * cd /var/www/app && php artisan schedule:run, then let Laravel decide when 03:00 actually fires. On multi-server setups the onOneServer() modifier prevents duplicate fan-outs.
No. There is no dedicated livestream endpoint. For Live content that appears in a creator's profile you can use /user-posts/, /user-stories/, or /post-detail/ against the regular post and profile endpoints.
Always X-Api-Key with your TikLiveAPI key. No bearer prefix, no query string fallback. The example client above sets it once on the PendingRequest so every method inherits it.
The /user-followers/ and /user-following/ endpoints paginate with a time integer that you echo back from the previous response. Almost everything else (posts, comments, search) uses a cursor string plus a hasMore boolean. Mixing them up is the most common integration bug.
For most cases yes. The example client retries three times with a short backoff and throw: false, which lets you decide per call whether to escalate. Combine it with the small CircuitBreaker service shown above so a sustained outage does not pile up failing jobs.
Open the interactive Playground. It proxies through the dashboard with your key injected server side, so you can inspect the exact JSON shape before binding Eloquent models. The same data drives the full documentation, and if you want to debug request flow visually, head to contact or browse the blog for related write-ups.
Ready to put what you read into code? Try our endpoints live or grab the full reference.