YouTube transcript API reference.

One POST returns the caption track you named, as JSON, plain text, SRT, or WebVTT. This page covers authentication, the request contract, polling, formats, idempotency, errors, and credits.

Staging contract

Version 1.0.0-draft.1. This contract is deployed only to isolated staging at https://api.transcriptlayer.com. Production access, paid checkout, and prices remain closed.

Authentication

Every request carries a named, scoped API key as a bearer token. Keys never appear in a URL, a query string, or a webhook payload. The dashboard issues, names, and revokes them.

Header
Authorization: Bearer tl_live_...

Your first transcript

Send a supported YouTube URL and say which caption track you want. The URL is parsed to a video ID and never fetched as a generic URL. Idempotency-Key is required on every request that can create work.

POST /v1/transcripts
curl https://api.transcriptlayer.com/v1/transcripts \
  -H "Authorization: Bearer $TRANSCRIPTLAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: import-row-1842" \
  -d '{
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "language_preferences": [
    "en"
  ],
  "caption_kinds": [
    "manual"
  ],
  "include": [
    "available_tracks"
  ]
}'

Request fields

FieldDefaultMeaning
urlSupported YouTube URL. Send this or video_id with platform.
platformSource platform. Required with video_id and currently fixed to youtube.
video_idExact 11-character YouTube video ID. Send this with platform instead of url.
track_idExact track from an earlier response. It cannot be combined with language matching.
language_preferencesOrdered BCP 47 tags. The first published match wins.
caption_kindsmanual, automaticRestrict language matching to human captions, machine captions, or both.
language_fallbacknonenone fails when the requested language is absent; any permits a substitute.
content_formatsegmentsInline segments, text, or both in the transcript response.
max_age_secondsOmit to accept any valid stored artifact. Zero forces a fresh observation.
allow_stale_on_errorfalseServe the stored artifact when an explicit refresh fails. Requires max_age_seconds.
includeAdd metadata, available_tracks, or both to the response.
webhook_endpoint_idSend the terminal event to one enabled endpoint owned by this account.

Sync and async in one call

The POST commits your request durably before it waits. If the track finishes inside the wait budget you get 200 with the whole transcript. If it does not, you get 202 with the same transcript ID, a poll_url, and a Retry-After delay. You never issue a second billable request either way.

  • Prefer: respond-async skips the wait and returns 202 immediately.
  • Prefer: wait=N sets the budget, where N is 0 through 10 seconds.
202 Accepted
HTTP/1.1 202 Accepted
Location: https://api.transcriptlayer.com/v1/transcripts/tr_7QxK2mYb
Retry-After: 2
X-Credits-Charged: 0

{
  "id": "tr_7QxK2mYb",
  "request_id": "req_5nD8vTca",
  "object": "transcript",
  "status": "processing",
  "source": {
    "platform": "youtube",
    "id": "dQw4w9WgXcQ",
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
  },
  "requested": {
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "language_preferences": [
      "en"
    ],
    "caption_kinds": [
      "manual"
    ],
    "language_fallback": "none",
    "content_format": "segments",
    "allow_stale_on_error": false,
    "include": [
      "available_tracks"
    ]
  },
  "usage": {
    "credits_charged": 0
  },
  "poll_url": "https://api.transcriptlayer.com/v1/transcripts/tr_7QxK2mYb",
  "created_at": "2026-08-22T09:41:05Z",
  "updated_at": "2026-08-22T09:41:05Z"
}

Poll GET /v1/transcripts/{transcript_id} until status is completed, failed, or cancelled. Polling is free and authoritative. Webhooks are advisory, at least once, and unordered.

One transcript schema

An immediate response, a poll, a batch item, and a JSON download all return the same object. Write your parser once.

200 OK
HTTP/1.1 200 OK
X-Credits-Charged: 1

{
  "id": "tr_7QxK2mYb",
  "request_id": "req_5nD8vTca",
  "object": "transcript",
  "status": "completed",
  "source": {
    "platform": "youtube",
    "id": "dQw4w9WgXcQ",
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
  },
  "requested": {
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "language_preferences": [
      "en"
    ],
    "caption_kinds": [
      "manual"
    ],
    "language_fallback": "none",
    "content_format": "segments",
    "allow_stale_on_error": false,
    "include": [
      "available_tracks"
    ]
  },
  "track": {
    "id": "yt.cc.en",
    "language": "en",
    "name": "English",
    "kind": "manual",
    "fallback_applied": false
  },
  "available_tracks": [
    {
      "id": "yt.cc.en",
      "language": "en",
      "name": "English",
      "kind": "manual"
    },
    {
      "id": "yt.asr.en",
      "language": "en",
      "name": "English (auto-generated)",
      "kind": "automatic"
    }
  ],
  "content": {
    "url": "https://api.transcriptlayer.com/v1/transcripts/tr_7QxK2mYb/content",
    "available_formats": [
      "text",
      "json",
      "srt",
      "vtt"
    ],
    "included": "segments",
    "segments": [
      {
        "start_ms": 0,
        "duration_ms": 4300,
        "text": "A committee had twelve weeks to decide"
      },
      {
        "start_ms": 4300,
        "duration_ms": 3900,
        "text": "what a passing civilization should hear."
      }
    ]
  },
  "retrieval": {
    "cache_status": "miss",
    "observed_at": "2026-08-22T09:41:07Z",
    "access_validated_at": "2026-08-22T09:41:07Z",
    "access_validation_age_seconds": 0,
    "cache_age_seconds": 0,
    "stale": false,
    "source_market": "US",
    "content_sha256": "3b1f8c0d94a7e2661bd5a08f7c4e19d2a6f30b5ce8471d92aa0c63e5f7182b4d"
  },
  "usage": {
    "credits_charged": 1
  },
  "created_at": "2026-08-22T09:41:05Z",
  "updated_at": "2026-08-22T09:41:08Z",
  "completed_at": "2026-08-22T09:41:08Z"
}
FieldTypeWhat it carries
idstringTranscript resource ID, prefixed tr_.
request_idstringCorrelation ID for this admission and its diagnostics.
objectenumAlways transcript.
statusenumqueued, processing, completed, failed, or cancelled.
sourceobjectResolved platform, canonical video ID, and canonical URL.
requestedobjectThe normalized source, selection, freshness, include, and webhook request.
trackobjectThe delivered track identity, language, name, kind, and fallback result.
available_tracksarrayThe bounded track inventory observed with the selected artifact.
metadataobjectOptional bounded title, channel, duration, and lazy thumbnail link.
contentobjectDownload URL, available formats, and any requested inline text or segments.
retrievalobjectObservation time, access validation, market, cache age, and content hash.
usageobjectCredits charged for this resource, always zero or one.
poll_urlstringPresent while the resource is queued or processing.
errorobjectPresent after failure with a stable code, detail, request ID, and retry policy.
created_atstringDurable admission time.
updated_atstringLast resource-state update time.
completed_atstringTerminal completion, failure, or cancellation time.

Exact track selection

Language alone is not a stable identifier. One video can publish several tracks with the same language and kind. TranscriptLayer never silently swaps a language or a caption source, and it never runs speech recognition to cover a track that was never published.

The default language_fallback is none. Ask for manual Spanish, and you get manual Spanish or the error track_not_available with zero credits charged.

Add available_tracks to include to receive the published track inventory. Take an id from that list and send it back as track_id to pin the exact same track on later calls.

Pin an exact track
{
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "track_id": "yt.cc.es"
}

Four downloads, one artifact

Segments are the normalized model: start_ms, duration_ms, and text. The four download formats are renderings of that one artifact, so your subtitle file and your search index cannot disagree.

formatMedia typeUse
jsonapplication/jsonSegments and text in the transcript content schema.
texttext/plainReading copy for embeddings, summarizers, and search.
srtapplication/x-subripNumbered cues with comma milliseconds, for editors and players.
vtttext/vttWebVTT for the browser <track> element.
GET /v1/transcripts/{id}/content
curl "https://api.transcriptlayer.com/v1/transcripts/tr_7QxK2mYb/content?format=srt" \
  -H "Authorization: Bearer $TRANSCRIPTLAYER_API_KEY" \
  -o episode.srt

Downloads are free and support If-None-Match, so a matching entity tag returns 304 instead of a body.

Idempotency

Idempotency-Key is required on every work-creating request, up to 200 characters. The service records the key, the normalized body hash, the resource ID, and the settlement atomically.

  • Same key, same body: you get the original resource back. No second charge.
  • Same key, different body: 409 Conflict.
  • A dropped connection, a retried queue delivery, or an ambiguous response never doubles a charge.

Keys are retained through non-terminal work and for seven days after the terminal state. Reusing a key after that window creates and charges a new request.

Errors

A request that was durably admitted and then failed returns 200 with status: "failed" and an error object. Transport and validation problems use normal HTTP status codes: 400, 401, 402, 403, 409, 422, 429, 500, and 503.

Admitted, then failed
HTTP/1.1 200 OK
X-Credits-Charged: 0

{
  "id": "tr_9LmP4nZq",
  "request_id": "req_2hV6bXsw",
  "object": "transcript",
  "status": "failed",
  "source": {
    "platform": "youtube",
    "id": "dQw4w9WgXcQ",
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
  },
  "requested": {
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "language_preferences": [
      "es"
    ],
    "caption_kinds": [
      "manual"
    ],
    "language_fallback": "none",
    "content_format": "segments",
    "allow_stale_on_error": false,
    "include": []
  },
  "usage": {
    "credits_charged": 0
  },
  "error": {
    "code": "track_not_available",
    "detail": "No manual Spanish track is published for this video.",
    "request_id": "req_2hV6bXsw",
    "retryable": false,
    "observed_at": "2026-08-22T09:41:08Z",
    "cache_status": "miss"
  },
  "created_at": "2026-08-22T09:41:05Z",
  "updated_at": "2026-08-22T09:41:08Z",
  "completed_at": "2026-08-22T09:41:08Z"
}
codeRetryableCause
source_not_foundnoNo video resolves from that URL or ID.
source_removednoThe video was deleted upstream.
source_privatenoThe video is private.
source_restrictednoThe video is age or region restricted.
source_live_not_readyyesAn active live stream has no final caption track yet.
transcript_unavailablenoThe video publishes no caption track.
track_not_availablenoThe exact language, kind, or track ID is not published.
source_too_largenoThe selected caption artifact exceeds the service limit.
upstream_enforcementyesThe platform blocked the retrieval attempt.
proxy_unavailableyesNo approved egress path was available.
temporarily_unavailableyesA dependency is degraded. Retry after the returned delay.
internal_erroryesAn unexpected service fault occurred. Retry is safe.

Credits

The billing unit is one completed selected track. A cache hit and a cache miss cost the same. Read the charge from usage.credits_charged, or the X-Credits-Charged response header.

  • Failed requests cost zero.
  • Polling, downloads, and webhook deliveries cost zero.
  • A cancellation that lands before the commit grant costs zero.

GET /v1/usage pages immutable credit entries. Customer-visible usage is kept for 12 months.

Batches

One batch takes up to 1,000 explicit items within a 131,072 byte body. Each item takes the same selection fields as a single request, plus your own reference string, which comes back on the matching result.

POST /v1/batches
POST /v1/batches
Idempotency-Key: nightly-2026-08-22

{
  "webhook_endpoint_id": "whe_7QxK2mYb",
  "items": [
    {
      "reference": "row-1",
      "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
      "language_preferences": [
        "en"
      ]
    },
    {
      "reference": "row-2",
      "video_id": "9bZkp7q19f0",
      "platform": "youtube",
      "language_preferences": [
        "ko"
      ],
      "caption_kinds": [
        "manual"
      ]
    }
  ]
}

Credits for the whole batch are reserved atomically. Duplicate references, and duplicate normalized source-plus-selection items, are rejected. A terminal batch is immutable: to retry failed items, create a new batch from them with a new idempotency key.

GET /v1/batches lists the account's batches from newest to oldest. Use its signed cursor to page history, then read one batch or its item results by ID.

Signed terminal webhooks

Register an HTTPS destination in the dashboard, then store its one-time signing secret under the displayed signing key ID. Your receiver must verify the signature over the exact body bytes before it parses JSON. Click Verify only after the receiver can return the supplied challenge.

  1. Reject a timestamp more than five minutes from your clock.
  2. Choose the secret by TranscriptLayer-Key-Id.
  3. Compute HMAC-SHA256 over v1:timestamp:event_id:api_version:key_id:body.
  4. Compare the lowercase hex digest in constant time.
  5. For endpoint.verification, return { "challenge": event.challenge } in a 2xx JSON response.
  6. For a terminal event, deduplicate by immutable event.id, queue the resource read, then acknowledge with 2xx.
Node.js receiver core
import { createHmac, timingSafeEqual } from "node:crypto";

const API_VERSION = "2026-08-21";
const MAX_AGE_SECONDS = 300;
const secrets = new Map([
  [process.env.TL_WEBHOOK_KEY_ID, process.env.TL_WEBHOOK_SECRET],
  [process.env.TL_WEBHOOK_PREVIOUS_KEY_ID, process.env.TL_WEBHOOK_PREVIOUS_SECRET],
].filter(([keyId, secret]) => keyId && secret));

async function readBody(request) {
  const declared = Number.parseInt(request.headers.get("content-length") ?? "0", 10);
  if (Number.isFinite(declared) && declared > 16 * 1024) return null;
  const reader = request.body?.getReader();
  if (!reader) return null;
  const chunks = [];
  let total = 0;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > 16 * 1024) {
      await reader.cancel("webhook body too large");
      return null;
    }
    chunks.push(value);
  }
  return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
}

function validSignature(rawBody, headers) {
  const eventId = headers.get("transcriptlayer-event-id") ?? "";
  const timestamp = headers.get("transcriptlayer-timestamp") ?? "";
  const candidates = [
    [headers.get("transcriptlayer-key-id"), headers.get("transcriptlayer-signature")],
    [headers.get("transcriptlayer-previous-key-id"), headers.get("transcriptlayer-previous-signature")],
  ];

  if (!/^evt_[A-Za-z0-9]+$/.test(eventId) || !/^\d{10,12}$/.test(timestamp)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > MAX_AGE_SECONDS) return false;

  return candidates.some(([keyId, supplied]) => {
    const secret = secrets.get(keyId);
    if (!secret || !/^v1=[a-f0-9]{64}$/.test(supplied ?? "")) return false;
    const prefix = Buffer.from(`v1:${timestamp}:${eventId}:${API_VERSION}:${keyId}:`);
    const expected = createHmac("sha256", secret).update(Buffer.concat([prefix, rawBody])).digest();
    const received = Buffer.from(supplied.slice(3), "hex");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

function validTerminalEvent(event) {
  const terminalTypes = [
    "transcript.completed", "transcript.failed", "transcript.cancelled",
    "batch.completed", "batch.cancelled",
  ];
  if (!event || event.object !== "event" || event.api_version !== API_VERSION
    || !terminalTypes.includes(event.type)) return false;
  const [object, status] = event.type.split(".");
  return event.resource?.object === object
    && event.resource.status === status
    && typeof event.resource.id === "string"
    && Number.isInteger(event.resource.version)
    && event.resource.version >= 1
    && typeof event.resource.url === "string";
}

export async function receive(request, enqueueOnce) {
  const rawBody = await readBody(request);
  if (!rawBody) return new Response(null, { status: 413 });
  if (!validSignature(rawBody, request.headers)) return new Response(null, { status: 401 });

  let event;
  try { event = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(rawBody)); }
  catch { return new Response(null, { status: 400 }); }
  if (event.id !== request.headers.get("transcriptlayer-event-id")
    || event.object !== "event" || event.api_version !== API_VERSION) {
    return new Response(null, { status: 400 });
  }
  if (event.type === "endpoint.verification") {
    if (!/^whch_[A-Za-z0-9_-]+$/.test(event.challenge ?? "") || !event.endpoint?.id) {
      return new Response(null, { status: 400 });
    }
    return Response.json({ challenge: event.challenge });
  }
  if (!validTerminalEvent(event)) return new Response(null, { status: 400 });

  await enqueueOnce(event.id, event.resource);
  return new Response(null, { status: 204 });
}

Add webhook_endpoint_id to one transcript request, or at the top level of one batch. Delivery is at least once and unordered. The event is a thin reference, not transcript content, so poll the authenticated resource.url for authoritative state.

Terminal event
{
  "id": "evt_7QxK2mYb",
  "object": "event",
  "api_version": "2026-08-21",
  "type": "transcript.completed",
  "created_at": "2026-08-22T09:41:08Z",
  "resource": {
    "id": "tr_7QxK2mYb",
    "object": "transcript",
    "status": "completed",
    "request_id": "req_5nD8vTca",
    "version": 1,
    "url": "https://api.transcriptlayer.com/v1/transcripts/tr_7QxK2mYb"
  }
}

Only a 2xx response acknowledges an attempt. TranscriptLayer retries network failures, HTTP 408, 425, 429, and 5xx responses for at most seven attempts over 24 hours. Keep the previous key and secret for the 24-hour rotation overlap; deliveries include both signature pairs during that window.

Every endpoint

  • GET/v1/statusRead coarse public service health
  • GET/v1/sessionRead the current browser owner session
  • GET/v1/accountRead account status and credit balances
  • POST/v1/account/closeClose the current account and begin durable erasure
  • GET/v1/api-keysList active API keys
  • POST/v1/api-keysCreate a named API key
  • POST/v1/api-keys/revoke-allRevoke every active API key
  • DELETE/v1/api-keys/{api_key_id}Revoke an API key
  • POST/v1/transcriptsGet or start one selected transcript track
  • GET/v1/transcriptsList this account's transcript requests
  • GET/v1/transcripts/{transcript_id}Poll or retrieve one transcript request
  • DELETE/v1/transcripts/{transcript_id}Hide and erase one customer transcript resource
  • POST/v1/transcripts/{transcript_id}/cancelCancel before the selected-track commit grant
  • GET/v1/transcripts/{transcript_id}/contentDownload a completed transcript representation
  • GET/v1/transcripts/{transcript_id}/thumbnailLazily materialize and download an optional thumbnail without exposing an upstream URL
  • GET/v1/batchesList this account's batches
  • POST/v1/batchesSubmit up to 1,000 explicit transcript items
  • GET/v1/batches/{batch_id}Read aggregate batch state
  • DELETE/v1/batches/{batch_id}Hide and erase a batch and its customer item resources
  • GET/v1/batches/{batch_id}/itemsCursor-page batch item results
  • POST/v1/batches/{batch_id}/cancelCancel every item that has not crossed its commit grant
  • POST/v1/webhook-endpointsRegister an HTTPS webhook destination
  • GET/v1/webhook-endpointsList registered webhook destinations
  • GET/v1/webhook-endpoints/{webhook_endpoint_id}Get one webhook destination
  • DELETE/v1/webhook-endpoints/{webhook_endpoint_id}Delete a webhook destination and exhaust its pending deliveries
  • POST/v1/webhook-endpoints/{webhook_endpoint_id}/verifyVerify control of a webhook destination
  • POST/v1/webhook-endpoints/{webhook_endpoint_id}/rotate-secretRotate an endpoint signing secret
  • GET/v1/webhook-deliveriesList webhook delivery history
  • GET/v1/webhook-deliveries/{webhook_delivery_id}Get one webhook delivery and its attempts
  • POST/v1/webhook-deliveries/{webhook_delivery_id}/replayCreate a new delivery for a terminal event
  • GET/v1/analytics/overviewRead account request analytics and recent diagnostics
  • GET/v1/analytics/requests/{request_id}Read one account request diagnostic
  • GET/v1/usageCursor-page immutable credit entries