segments comes back null when the transcript was built from a platform caption file instead of from audio. Our subtitle path converts WebVTT or SRT into one flat string and drops the timing cues on the way through. Only the speech-to-text leg reports timings, so only the transcripts it produces carry a populated segments array.
What are the two response shapes?
Every transcript endpoint returns the same four fields inside the standard { data, meta?, request_id } envelope: text, segments, language, source.
Caption-derived, which is what TikTok returns today:
{ "data": { "text": "Today I want to show you the three settings everyone ignores when they set this up...", "segments": null, "language": "en", "source": "upstream" }}Audio-derived:
{ "data": { "text": "Today I want to show you the three settings everyone ignores when they set this up...", "segments": [ { "start": 0, "end": 3.2, "text": "Today I want to show you the three settings" }, { "start": 3.2, "end": 5.9, "text": "everyone ignores when they set this up" } ], "language": "en", "source": "whisper" }}source is the field that tells you which one you got. upstream means a caption track the platform already published. whisper means our own speech-to-text pass, which reports timings alongside the text.
One rule covers the whole thing: source: "upstream" implies segments: null.
Why does the caption path lose the timings?
Because the converter is a text extractor, not a parser.
When the pipeline finds a linked subtitle file, it fetches it and runs it through a small function that splits on newlines and throws away everything that isn’t spoken words: the WEBVTT header, the cue index numbers, the Kind/Language/NOTE/STYLE/REGION headers, and any line containing the cue-timing arrow. Whatever survives gets joined with spaces and whitespace-collapsed into a single string.
Worth being blunt about this: for a linked .vtt file the timings were in the file, and we dropped them. That is a property of our subtitle converter, not a limitation of the platform.
The other caption case has no timings to begin with. Some scrape results carry the subtitle text inline as a plain string rather than a file URL. When that is present, the pipeline takes it directly and writes segments: null without ever seeing a cue.
Either way, the transcript that lands in storage is flat.
Which platforms return timestamps and which don’t?
| Platform | Path that can serve it | segments |
|---|---|---|
| TikTok | Caption track shipped inside the scrape result | null |
| Speech-to-text only | Populated, when it runs | |
| Speech-to-text only | Populated, when it runs | |
| X | Speech-to-text only | Populated, when it runs |
Two things make that table less useful than it looks.
First, the subtitle shortcut is only wired for TikTok. It runs when the actor registry entry carries both a transcript-included flag and a subtitle extractor, and only the TikTok entries do. That is why the TikTok Transcript API succeeds on a large share of URLs and the others don’t.
Second, the speech-to-text leg is switched off at launch. With no STT key configured, the pipeline stops before downloading media and returns a clean 404 not_found with the message “no transcript is available for this content”. So Instagram, Facebook and X return that 404 today rather than a transcript with timestamps. The Facebook Transcript API page says the same thing in more detail. Failed calls are not charged.
Put the two together and the current state is: the only platform that returns transcripts is the one platform that can never return timestamps.
There is a second-order effect that catches people. The subtitle branch runs before the audio branch. So even after speech-to-text is enabled, TikTok will keep returning segments: null, because the free caption track is found first and the pipeline returns on it. Timestamps for TikTok would need a different change than flipping the STT switch.
If segments starts working, will my old transcripts backfill?
No.
Transcripts are in the immutable cache class. The pipeline looks up the stored transcript before it does anything else, and returns it if one exists. A video transcribed from captions today is stored with segments: null, and every later call for that video returns that stored row.
fresh=true does not get you around it. The stored-transcript lookup happens ahead of any re-fetch, so a video already in the corpus is served from the corpus regardless. This is the intended behavior for content whose spoken words can’t change after posting, and it’s covered in caching and freshness, but it does mean a corpus built now is a corpus without timings.
If timestamps ever become load-bearing for you, keep your own record of which video IDs you pulled and when, so you know which ones predate any change.
How do I write a consumer that treats segments as optional?
Type it nullable and branch on it. text is the contract; everything else is a bonus.
type Segment = { start: number; end: number; text: string };
type Transcript = { text: string; segments: Segment[] | null; language: string | null; source: string;};
const { data } = await res.json() as { data: Transcript };
// Always safe.index(data.text);
// Only when the audio leg produced it.if (data.segments?.length) { renderJumpLinks(data.segments);} else { renderPlainTranscript(data.text);}Two ways this breaks in practice. One is data.segments.map(...) throwing on null the first time a TikTok URL hits a code path that was written and tested against Facebook. The other is subtler: a consumer that treats a missing segments array as “no transcript” and discards perfectly good text.
Handle the 404 branch separately from the null-segments branch. They mean different things. A 404 means there is no transcript. segments: null means there is a transcript and it has no timings.
What can you still do with flat text?
Most of what transcripts get used for.
A flat string is fine for search indexing, for classification and tagging, for feeding an LLM, for claim and keyword detection across a creator’s catalogue, and for matching the same script across platforms. Most TikToks run under a minute, so the text is usually a few hundred words, small enough to drop into a prompt or a database column without chunking. If you are pulling a whole account, the transcribe-a-channel recipe walks through the enumerate-then-transcribe loop, and how to get a TikTok transcript in Python covers the single-video call.
What you can’t do is anything positional: jump-to-moment links, scrubber alignment, clip cutting, or regenerating a subtitle file.
Don’t try to reconstruct the timings. Slicing the string by character offset and scaling by video duration produces numbers, not timestamps, and speech rate isn’t constant enough for them to hold up. If you need real alignment, pull the media with a download endpoint such as the TikTok Video Download API and run your own speech-to-text over it. You’ll get timings, and you’ll own the accuracy trade-off rather than inheriting ours.