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.
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.
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
| Field | Default | Meaning |
|---|---|---|
url | — | Supported YouTube URL. Send this or video_id with platform. |
platform | — | Source platform. Required with video_id and currently fixed to youtube. |
video_id | — | Exact 11-character YouTube video ID. Send this with platform instead of url. |
track_id | — | Exact track from an earlier response. It cannot be combined with language matching. |
language_preferences | — | Ordered BCP 47 tags. The first published match wins. |
caption_kinds | manual, automatic | Restrict language matching to human captions, machine captions, or both. |
language_fallback | none | none fails when the requested language is absent; any permits a substitute. |
content_format | segments | Inline segments, text, or both in the transcript response. |
max_age_seconds | — | Omit to accept any valid stored artifact. Zero forces a fresh observation. |
allow_stale_on_error | false | Serve the stored artifact when an explicit refresh fails. Requires max_age_seconds. |
include | — | Add metadata, available_tracks, or both to the response. |
webhook_endpoint_id | — | Send 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-asyncskips the wait and returns 202 immediately.Prefer: wait=Nsets the budget, where N is 0 through 10 seconds.
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.
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"
}| Field | Type | What it carries |
|---|---|---|
id | string | Transcript resource ID, prefixed tr_. |
request_id | string | Correlation ID for this admission and its diagnostics. |
object | enum | Always transcript. |
status | enum | queued, processing, completed, failed, or cancelled. |
source | object | Resolved platform, canonical video ID, and canonical URL. |
requested | object | The normalized source, selection, freshness, include, and webhook request. |
track | object | The delivered track identity, language, name, kind, and fallback result. |
available_tracks | array | The bounded track inventory observed with the selected artifact. |
metadata | object | Optional bounded title, channel, duration, and lazy thumbnail link. |
content | object | Download URL, available formats, and any requested inline text or segments. |
retrieval | object | Observation time, access validation, market, cache age, and content hash. |
usage | object | Credits charged for this resource, always zero or one. |
poll_url | string | Present while the resource is queued or processing. |
error | object | Present after failure with a stable code, detail, request ID, and retry policy. |
created_at | string | Durable admission time. |
updated_at | string | Last resource-state update time. |
completed_at | string | Terminal 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.
{
"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.
| format | Media type | Use |
|---|---|---|
json | application/json | Segments and text in the transcript content schema. |
text | text/plain | Reading copy for embeddings, summarizers, and search. |
srt | application/x-subrip | Numbered cues with comma milliseconds, for editors and players. |
vtt | text/vtt | WebVTT for the browser <track> element. |
curl "https://api.transcriptlayer.com/v1/transcripts/tr_7QxK2mYb/content?format=srt" \
-H "Authorization: Bearer $TRANSCRIPTLAYER_API_KEY" \
-o episode.srtDownloads 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.
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"
}| code | Retryable | Cause |
|---|---|---|
source_not_found | no | No video resolves from that URL or ID. |
source_removed | no | The video was deleted upstream. |
source_private | no | The video is private. |
source_restricted | no | The video is age or region restricted. |
source_live_not_ready | yes | An active live stream has no final caption track yet. |
transcript_unavailable | no | The video publishes no caption track. |
track_not_available | no | The exact language, kind, or track ID is not published. |
source_too_large | no | The selected caption artifact exceeds the service limit. |
upstream_enforcement | yes | The platform blocked the retrieval attempt. |
proxy_unavailable | yes | No approved egress path was available. |
temporarily_unavailable | yes | A dependency is degraded. Retry after the returned delay. |
internal_error | yes | An 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
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.
- Reject a timestamp more than five minutes from your clock.
- Choose the secret by
TranscriptLayer-Key-Id. - Compute HMAC-SHA256 over
v1:timestamp:event_id:api_version:key_id:body. - Compare the lowercase hex digest in constant time.
- For
endpoint.verification, return{ "challenge": event.challenge }in a 2xx JSON response. - For a terminal event, deduplicate by immutable
event.id, queue the resource read, then acknowledge with 2xx.
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.
{
"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