Full reference for the TikTok Transcript API — turn a TikTok or YouTube video URL into a transcript.
Base URL: https://painted-labs.com — auth with
Authorization: Bearer sk_… (create keys in the
dashboard). All endpoints speak JSON. Jobs are async: submit, then poll.
Fields marked $ cost extra and are off by default.
/v1/jobs
Submit one video for transcription — or set metadataOnly: true for a free metadata-only pull. The spoken language is auto-detected — you don't (and can't) specify it.
A TikTok or YouTube video URL.
Skip transcription (and its fee): title, description, duration, views, likes, comments, uploader, post date.
Also return the transcript translated to this language, e.g. "en", "es".
Also return a ready-to-save subtitle file built from the timestamps. Free.
Set false to leave the per-phrase timestamped segments out of the response.
curl -X POST https://painted-labs.com/v1/jobs \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://www.tiktok.com/@user/video/7234567890123456789", "translateTo": "en"}'
import requests
r = requests.post(
"https://painted-labs.com/v1/jobs",
headers={"Authorization": "Bearer sk_..."},
json={"url": "https://www.tiktok.com/@user/video/7234567890123456789", "translateTo": "en"},
)
print(r.json()) # {"id": "job_a1b2c3d4e5f6...", "status": "IN_QUEUE"}
const res = await fetch("https://painted-labs.com/v1/jobs", {
method: "POST",
headers: {
"Authorization": "Bearer sk_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://www.tiktok.com/@user/video/7234567890123456789",
translateTo: "en",
}),
});
console.log(await res.json());
<?php
$ch = curl_init("https://painted-labs.com/v1/jobs");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_...",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"url" => "https://www.tiktok.com/@user/video/7234567890123456789",
"translateTo" => "en",
]),
]);
echo curl_exec($ch);
{"id": "job_a1b2c3d4e5f6...", "status": "IN_QUEUE"}
/v1/jobs/:id
Poll a job until it reaches a terminal status: COMPLETED, FAILED, CANCELLED, or TIMED_OUT. Non-COMPLETED terminal jobs carry a human-readable error field and are automatically refunded.
translateTo; subtitles only with subtitleFormat; segments is omitted when includeSegments is false.
A completed job with empty text and a positive duration means the audio was processed and contains no speech (music-only video) — that's a result, not an error.
A metadataOnly job's output has no transcript; instead it carries the full metadata field set:
title, description, duration, viewCount, likeCount, commentCount, uploader, uploaderId, postedAt.
curl https://painted-labs.com/v1/jobs/job_a1b2c3d4e5f6... \ -H "Authorization: Bearer sk_..."
import time, requests
job_id = "job_a1b2c3d4e5f6..."
while True:
job = requests.get(
f"https://painted-labs.com/v1/jobs/{job_id}",
headers={"Authorization": "Bearer sk_..."},
).json()
if job["status"] in ("COMPLETED", "FAILED", "CANCELLED", "TIMED_OUT"):
break
time.sleep(3)
print(job["output"]["text"])
const jobId = "job_a1b2c3d4e5f6...";
let job;
do {
await new Promise((r) => setTimeout(r, 3000));
const res = await fetch(`https://painted-labs.com/v1/jobs/${jobId}`, {
headers: { "Authorization": "Bearer sk_..." },
});
job = await res.json();
} while (!["COMPLETED", "FAILED", "CANCELLED", "TIMED_OUT"].includes(job.status));
console.log(job.output.text);
<?php
$jobId = "job_a1b2c3d4e5f6...";
do {
sleep(3);
$ch = curl_init("https://painted-labs.com/v1/jobs/$jobId");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer sk_..."],
]);
$job = json_decode(curl_exec($ch), true);
} while (!in_array($job["status"], ["COMPLETED", "FAILED", "CANCELLED", "TIMED_OUT"]));
echo $job["output"]["text"];
{"id": "job_a1b2c3d4e5f6...", "status": "COMPLETED",
"output": {
"text": "full transcript ...",
"language": "es",
"duration": 42.7,
"title": "...", "description": "...",
"segments": [
{"start": 0.0, "end": 3.2, "text": "first phrase"},
{"start": 3.2, "end": 6.9, "text": "second phrase"}
],
"translation": "full transcript, in English ...",
"translatedTo": "en",
"subtitles": "1\n00:00:00,000 --> 00:00:03,200\nfirst phrase\n..."
}}
metadataOnly pulls (they're free) — a transcription's output carries the transcript plus title/description/duration./v1/expand
Turn a whole TikTok profile, YouTube channel/playlist, or YouTube search into per-video results: every discovered video gets its own metadata pull — same rate as submitting each URL yourself. One call in, one batch id out; poll it below. A bare expand is metadata only.
Profile/channel/playlist URL, or a bare TikTok @handle. Provide this or search.
YouTube keyword search.
How many videos to take from the source (alias limit).
Only include videos posted in this range.
Only include videos with at least this many views.
Order in which the source's videos are taken before the cap (alias sort).
Also transcribe every discovered video — billed per started minute, like /v1/jobs.
Also translate each video's transcript. Requires transcribe: true.
Applied to each video.
skipped and nothing more is charged. Every discovered video is billed each time you expand — cache the results in your own store if you don't want to pay for them again.
curl -X POST https://painted-labs.com/v1/expand \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{"url": "@bromabakery", "maxVideosPerSource": 10, "sourceSorting": "mostViewed"}'
import requests
r = requests.post(
"https://painted-labs.com/v1/expand",
headers={"Authorization": "Bearer sk_..."},
json={"url": "@bromabakery", "maxVideosPerSource": 10, "sourceSorting": "mostViewed"},
)
print(r.json()) # {"id": "exp_9f2c...", "kind": "tiktok-profile", "scanned": 87, "total": 10}
const res = await fetch("https://painted-labs.com/v1/expand", {
method: "POST",
headers: {
"Authorization": "Bearer sk_...",
"Content-Type": "application/json",
},
body: JSON.stringify({ url: "@bromabakery", maxVideosPerSource: 10, sourceSorting: "mostViewed" }),
});
console.log(await res.json());
<?php
$ch = curl_init("https://painted-labs.com/v1/expand");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_...",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"url" => "@bromabakery", "maxVideosPerSource" => 10, "sourceSorting" => "mostViewed",
]),
]);
echo curl_exec($ch);
{"id": "exp_9f2c...", "kind": "tiktok-profile", "scanned": 87, "total": 10}
/v1/expand/:id
Poll the whole batch with one call until status is done. Each completed video carries the full metadata field set — plus transcript, subtitles and translation when you asked for them.
pending → submitted → completed / failed / skipped (out of credits or allowance).
Batch views are kept in memory for a limited window — every submitted video also has a jobId that stays pollable at GET /v1/jobs/:jobId.
curl https://painted-labs.com/v1/expand/exp_9f2c... \ -H "Authorization: Bearer sk_..."
import time, requests
while True:
batch = requests.get(
"https://painted-labs.com/v1/expand/exp_9f2c...",
headers={"Authorization": "Bearer sk_..."},
).json()
if batch["status"] == "done":
break
time.sleep(3)
for v in batch["videos"]:
print(v["url"], v.get("viewCount"), v.get("title"))
let batch;
do {
await new Promise((r) => setTimeout(r, 3000));
const res = await fetch("https://painted-labs.com/v1/expand/exp_9f2c...", {
headers: { "Authorization": "Bearer sk_..." },
});
batch = await res.json();
} while (batch.status !== "done");
console.log(batch.videos);
<?php
do {
sleep(3);
$ch = curl_init("https://painted-labs.com/v1/expand/exp_9f2c...");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer sk_..."],
]);
$batch = json_decode(curl_exec($ch), true);
} while ($batch["status"] !== "done");
print_r($batch["videos"]);
{"id": "exp_9f2c...", "kind": "tiktok-profile", "scanned": 87,
"status": "done", "done": 10, "total": 10,
"videos": [
{"url": "https://www.tiktok.com/@bromabakery/video/7...", "status": "completed",
"jobId": "job_a1b2c3d4e5f6...", "title": "...", "description": "...", "duration": 34,
"viewCount": 128000, "likeCount": 9200, "commentCount": 310,
"uploader": "Broma Bakery", "uploaderId": "@bromabakery",
"postedAt": "2026-06-30T12:00:00.000Z"}, ...]}
/v1/usage
Your credit balance and month-to-date usage.
curl https://painted-labs.com/v1/usage \ -H "Authorization: Bearer sk_..."
import requests
r = requests.get(
"https://painted-labs.com/v1/usage",
headers={"Authorization": "Bearer sk_..."},
)
print(r.json())
const res = await fetch("https://painted-labs.com/v1/usage", {
headers: { "Authorization": "Bearer sk_..." },
});
console.log(await res.json());
<?php
$ch = curl_init("https://painted-labs.com/v1/usage");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer sk_..."],
]);
echo curl_exec($ch);
{"creditsUsd": 9.94, "videos": 12, "transcripts": 0, "minutes": 4, "translations": 0,
"freeLimits": {"videos": 25, "transcripts": 1}}
Errors & limits
Missing or invalid API key.
Free-tier cap reached or out of credits — buy a pack in the dashboard. A transcription of unknown length reserves its worst case up front (20 min × the minute rate) and refunds whatever the video doesn't use when it completes, so a low balance can be refused even for a short video.
The video is unavailable — deleted, private, or a wrong URL. Nothing is charged; retry only if you believe the video exists.
Rate limit (120 req/min), too many jobs in flight, or too many expansions running. Honor the Retry-After header.
Transient — retry with backoff.
Transcription handles videos up to 20 minutes (free tier: 15). Longer videos fail with a clear error before any GPU runs and are never charged. Metadata pulls have no duration limit.
Failed, cancelled, or timed-out jobs — and completed transcriptions that produce no text — are refunded automatically, including in expansions. If a requested translation can't be delivered, the job carries a translationError field and the translation fee is not charged.