Fetch a YouTube video and get its transcript back as Markdown, with title, description, chapters, and timestamps.
Agents get sent to YouTube too. A conference talk, a tutorial, a product demo. The watch page itself is a player, a sidebar of recommendations, and a comment thread. What your agent needs is what was said.
Fetch returns the transcript on the endpoint you already call. Send the video URL and get the transcript back. POST /v1/fetch recognises a single-video YouTube URL and returns the video's transcript as Markdown, with title, channel, description, and chapters, instead of rendering the watch page.
curl https://api.expand.ai/v1/fetch \
-H "x-expand-api-key: $EXPAND_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://www.youtube.com/watch?v=aBcDeFgHiJk"}'Any URL that names one video takes the transcript route. Everything else on YouTube is fetched as an ordinary page.
| Form | Example |
|---|---|
| Watch page | https://www.youtube.com/watch?v=aBcDeFgHiJk |
| Short link | https://youtu.be/aBcDeFgHiJk |
| Shorts | https://www.youtube.com/shorts/aBcDeFgHiJk |
| Live replay |
youtube.com, www.youtube.com, m.youtube.com, music.youtube.com, and youtube-nocookie.com all count. Share noise in the query string (si, t, feature, a playlist list next to a v) is ignored, and the video id is what matters.
Playlists, channels, @handle pages, and search results carry no single video id, so they render in a browser and come back as a page. See Browser Behavior.
POST /v1/fetch returns the transcript as one Markdown document. The front matter uses the same keys a page fetch does, so nothing downstream needs a new parser.
---
url: "https://www.youtube.com/watch?v=aBcDeFgHiJk"
capturedAt: "2026-09-08T10:15:00.000Z"
pageType: "video"
title: "Keynote: What changed in the browser this year"
author: "Example Conf"
createdAt: "2026-05-14"
image: "https://i.ytimg.com/vi/aBcDeFgHiJk/maxresdefault.jpg"
---
# Keynote: What changed in the browser this year
Example Conf · 41:07 · Published 2026-05-14 · 128,304 views
Transcript: captions (manual, en)
## Description
Opening keynote from Example Conf 2026. Slides: https://example.com/slides
## Transcript
### [0:00]
Reading it top to bottom:
Transcript: line. Where the text came from. captions (manual, en), captions (auto-generated, en), or transcription (Deepgram, en). See Captions or transcription.### [m:ss] Title headings appear only when the uploader set chapters. Every chapter is kept, even one nobody speaks in, so the outline survives in full.[0:42] under an hour and [1:02:03] past it. Cues are merged into paragraphs at pauses and sentence ends, so the token count stays close to the plain transcript. Sound-only captions such as [Music] or [Applause] are dropped.POST /v1/fetch/json returns the same Markdown plus a typed data.video block, so a JSON-native caller does not parse the front matter or the Transcript: line back out.
{
"meta": {
"version": 1,
"url": "https://www.youtube.com/watch?v=aBcDeFgHiJk",
"capturedAt": "2026-09-08T10:15:00.000Z",
"snapshotId": "0f9c3a1e-...",
"contentType":
| Field | Meaning |
|---|---|
data.video.videoId | The eleven-character id, whichever URL form you sent. |
data.video.title, channel, channelUrl | As YouTube reports them. channel and channelUrl are absent when unknown. |
data.video.durationSeconds | Length of the video, not of the transcript. |
State JSON, Appendix, links, screenshots, and browser replay exist only because a browser rendered something, so a video fetch has none of them. Asking for them does not fail the request. The fields are simply absent. The playground shows the transcript next to the embedded player.
Fetch prefers text that already exists over text it has to generate. It tries, in order:
Only when none of those exists does Fetch download the audio and transcribe it with a speech model.
The Transcript: line and data.video.transcript tell you which you got:
source | kind | What it means |
|---|---|---|
captions | manual | The creator uploaded or corrected this track. The most faithful text on offer. |
captions | auto | YouTube's own speech recognition. Usually unpunctuated and occasionally wrong on names and numbers. |
language is a BCP-47 tag (en, de, pt-BR) when known. There is no option to request a specific language yet. A video in German with German captions returns German, because a translated auto-generated track is the least faithful text available.
Transcription is the only step that costs more than the fetch, and documentConfig.maxAudioSeconds bounds it. Every field is optional.
| Field | Type | Default | Range | What it does |
|---|---|---|---|---|
maxAudioSeconds | integer | 3600 | 1 to 14400 | Refuse the fetch rather than transcribe a video longer than this. |
Fetch checks the video's length against the limit before it downloads a byte of audio, so a refusal costs you nothing. The captions path ignores the limit entirely. A six-hour livestream replay with captions comes back in full for the flat fetch price.
curl https://api.expand.ai/v1/fetch \
-H "x-expand-api-key: $EXPAND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://youtu.be/aBcDeFgHiJk",
"documentConfig": { "maxAudioSeconds": 900 }
}'Raise the limit only as far as the videos you expect. Four hours is the ceiling, and transcription runs inside the same 150-second request budget as every fetch, so a video near the ceiling can run out of time and return 504 FetchCaptureTimeout.
A video costs 6 credits, whatever its length. That is the one-credit Fetch plus a five-credit video unit, and it covers the transcript, the timestamps, the chapters, and the frames. 1,000 credits cost $1, so six credits is under a cent.
When a video has no caption track we transcribe the audio, which charges minutes on top:
| Unit | Credits | When it applies |
|---|---|---|
| Video | 5 per video | Every delivered transcript. One per fetch, whatever the runtime. |
| Audio | 10 per minute | Only when no caption track exists. Rounded up, one-minute minimum. |
A 25-minute video with captions costs 6 credits. The same video with no captions costs 6 + 25 × 10 = 256 credits. Refused fetches cost nothing at all, and data.usage reports videos and audioSeconds so you can reconcile the charge against what you got. See Pricing & Usage.
A video fetch returns three statuses a page fetch never does:
_tag | Code | Meaning |
|---|---|---|
FetchVideoUnavailable | 422 | YouTube will not serve this video to anyone in our position. availability says why. Not retryable. |
FetchDocumentProcessingUnavailable | 503 | means YouTube refused our request, a bot check or a rate limit. means the speech provider could not finish. Both are retryable. |
availability on a 422 is one of:
availability | The video is |
|---|---|
private | Private. |
removed | Deleted, or its account was terminated. |
geoRestricted | Not available from Expand's region. |
ageRestricted |
{
"_tag": "FetchVideoUnavailable",
"url": "https://www.youtube.com/watch?v=aBcDeFgHiJk",
"availability": "live",
"message": "This live event will begin in 2 hours"
}A live or upcoming stream is worth fetching again after it ends, once YouTube has processed the replay. The other reasons do not change on retry.
YouTube answers 200 for all of these, so a video fetch never returns 502 FetchOriginHttpError. The refusal is a fact about the video, not about the HTTP exchange, and 422 says so. The Errors reference has the full list and the payload shapes.
Batched Fetch does not take the transcript route yet. A YouTube URL in a batch renders the watch page in a browser and comes back as a page. Send video URLs to POST /v1/fetch or POST /v1/fetch/json until this lands.
413, 422, and 503 in your client.https://www.youtube.com/live/aBcDeFgHiJk| Embed | https://www.youtube.com/embed/aBcDeFgHiJk |
| Legacy player | https://www.youtube.com/v/aBcDeFgHiJk |
data.video.publishedAt | Upload date as an ISO calendar date. |
data.video.viewCount | View count at capture time. |
data.video.language | The video's primary spoken language, when YouTube or the transcript reports one. |
data.video.transcript | source, kind, and language of the transcript. See below. |
data.document.url | A presigned link to what the transcript was made from: the caption track as JSON, or the audio that was transcribed. |
data.usage.audioSeconds | Seconds of audio transcribed. 0 on the captions path. |
meta.url | Always the canonical watch?v= URL. The URL you sent is data.response.requestedUrl when it differs. |
meta.contentType | text/html, because that is what YouTube served. The transcript is Expand's rendering of it, the same way a PDF's Markdown is. |
transcription | absent | No caption track existed. Expand transcribed the audio. Punctuated, paragraphed, language detected. |
operation: "video"operation: "transcription"FetchPageTooLarge | 413 | limitKind: "audio_seconds" means the video is longer than maxAudioSeconds. limit and measured are in seconds. |
| Behind an age gate that needs a signed-in account. |
membersOnly | Reserved for channel members or Premium subscribers. |
live | Streaming right now. There is no transcript until it ends. |
upcoming | A scheduled premiere or stream that has not started. |
unknown | Withheld for a reason YouTube did not name. |