How to get a YouTube transcript in Node.js

Retrieve YouTube transcripts in Node.js with the Capslane SDK or fetch, handle generated jobs and keep requests within a deadline.

Start from a trusted Node.js runtime

Use Node.js 22 or later for these examples. Create a Capslane key in your workspace and set CAPSLANE_API_KEY in the server environment. The key belongs in your backend or worker, not a React bundle or a public environment variable.

Install
npm install @webba_tech/capslane

Save the quickstart in an .mjs file and run it with Node. The public fixture exercises a real YouTube identifier. Free workspaces include 50 requests per month and 15 generated minutes; choose native mode if your first test should never start generation.

Handle both immediate results and accepted jobs

Node.js SDK
import { CapslaneClient, CapslaneError } from '@webba_tech/capslane'

const apiKey = process.env.CAPSLANE_API_KEY
if (!apiKey) throw new Error('Set CAPSLANE_API_KEY')
const client = new CapslaneClient({ apiKey, timeoutMs: 45_000 })
const signal = AbortSignal.timeout(20 * 60_000)

try {
  let result = await client.transcript({ url: 'dQw4w9WgXcQ', mode: 'auto', signal })
  if (!('content' in result)) {
    console.error('Accepted job:', result.jobId)
    result = await client.waitForTranscript(result, { signal })
  }
  console.log(JSON.stringify(result, null, 2))
} catch (error) {
  if (error instanceof CapslaneError) {
    console.error(error.status, error.code, error.requestId)
  }
  throw error
}

Download transcript-sdk.mjs and run node transcript-sdk.mjs.

The shared abort signal bounds the whole operation, including requests and waits. The SDK also applies a per-request timeout. HTTP 202 means that a job was accepted; it is not an HTTP failure. A completed result has content, whether it came from captions, generation or cache.

Capslane checks the cache before applying the requested mode. A cached result can come from native captions or audio generation in any mode. Check source and cached in the response; mode controls what happens on a cache miss.

On a cache miss, auto mode starts generation only after a confirmed absence of native captions. A network failure remains an error.

The JavaScript SDK repository contains the client types and tests. The request header is x-api-key; the API base is https://capslane.com.

Resume a saved job

Install SDK 0.1.4 or later. The packaged importTranscript workflow submits once, awaits the saved job record and returns the source URL, the original transcript and formatted timestampedText. This example uses native mode. Change it to auto when audio generation is authorized.

Complete import and resume
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { CapslaneClient } from '@webba_tech/capslane'
import { importTranscript, resumeTranscript } from '@webba_tech/capslane/workflows'

// Node.js 22+. Set CAPSLANE_API_KEY in your server environment.
// Submit: node import-transcript.mjs dQw4w9WgXcQ
// Resume: node import-transcript.mjs --resume ./transcript-jobs/JOB_ID.json
const client = new CapslaneClient({ apiKey: process.env.CAPSLANE_API_KEY ?? '', timeoutMs: 45_000 })
const saveJob = async job => {
  await mkdir('transcript-jobs', { recursive: true, mode: 0o700 })
  if (!/^job_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(job.jobId)) throw new Error('Invalid job ID')
  await writeFile(`transcript-jobs/${job.jobId}.json`, JSON.stringify(job), { mode: 0o600, flag: 'wx', flush: true })
}

try {
  const imported = process.argv[2] === '--resume'
    ? await resumeTranscript(client, JSON.parse(await readFile(process.argv[3], 'utf8')))
    : await importTranscript(client, {
      url: process.argv[2],
      mode: 'native', // Change to auto when audio generation is authorized.
      saveJob, timeoutMs: 20 * 60_000, intervalMs: 2_000,
    })
  console.log(imported.url)
  console.log(imported.timestampedText)
  // Original segments: imported.transcript.content. Offsets and durations are milliseconds.
} catch (error) {
  console.error(JSON.stringify({ error: error.code ?? error.name, jobId: error.jobId, requestId: error.requestId, url: error.url }))
  process.exitCode = 1
}

Download import-transcript.mjs. Run node import-transcript.mjs dQw4w9WgXcQ with CAPSLANE_API_KEY set. Each accepted job gets its own file with its video URL. To resume, pass --resume and that file path. The program never picks another video's saved job automatically.

The original segments are in imported.transcript.content. The workflow formats their millisecond offsets as HH:MM:SS. Both import and resume retain jobId on storage, network and formatting errors. They stop on failed, cancelled or completed without content. A local timeout ends the wait while the server can continue working.

Use a durable record per tenant and import in a service. Resolve saveJob only after storage succeeds, with a timeout appropriate to your database. Status polling does not consume another transcript unit. A fresh submission does.

The earlier low-level helper remains available: Download resume-transcript.mjs. Its client.transcriptJob(jobId, signal) call takes an AbortSignal directly. The SDK wait helper takes an options object: client.waitForTranscript(jobId, { signal }).

Use fetch directly with a shared deadline

The following module is a complete HTTP alternative. Every response is checked, including polling responses. It keeps the accepted job ID stable even when a processing response does not repeat it, and checks the remaining time before the next network call.

Complete fetch example
// Run on a server with CAPSLANE_API_KEY set. Node.js 22 or later.
export async function getTranscript(video, {
  apiKey = process.env.CAPSLANE_API_KEY,
  mode = 'auto',
  baseUrl = 'https://capslane.com',
  timeoutMs = 20 * 60_000,
  intervalMs = 2_000,
  fetch: fetcher = globalThis.fetch,
} = {}) {
  if (!apiKey) throw new Error('Set CAPSLANE_API_KEY on your server')
  const deadline = Date.now() + timeoutMs
  async function get(path) {
    const remaining = deadline - Date.now()
    if (remaining <= 0) throw new Error('Transcript job deadline exceeded')
    const response = await fetcher(`${baseUrl}${path}`, {
      headers: { 'x-api-key': apiKey, accept: 'application/json' },
      signal: AbortSignal.timeout(Math.max(1, Math.min(45_000, remaining))),
    })
    if (!response.ok) {
      const error = await response.json().catch(() => ({}))
      throw new Error(`${response.status} ${error.error ?? 'request_failed'}: ${error.message ?? response.statusText} (request ${error.requestId ?? 'unknown'})`)
    }
    return response.json()
  }

  const query = new URLSearchParams({ url: video, mode })
  let result = await get(`/v1/transcript?${query}`)
  const jobId = result.jobId
  while (!('content' in result)) {
    if (result.status === 'failed' || result.status === 'cancelled') {
      throw new Error(`${result.error ?? result.status} (request ${result.requestId ?? 'unknown'})`)
    }
    if (!jobId) throw new Error('Response contains neither content nor a job ID')
    const remaining = deadline - Date.now()
    if (remaining <= 0) throw new Error('Transcript job deadline exceeded')
    await new Promise((resolve) => setTimeout(resolve, Math.min(Math.max(1, intervalMs), remaining)))
    result = await get(`/v1/transcript/${encodeURIComponent(jobId)}`)
  }
  return result
}

Download transcript-http.mjs beside your application file, then call it as follows:

Run the module
import { getTranscript } from './transcript-http.mjs'

const result = await getTranscript('dQw4w9WgXcQ')
console.log(JSON.stringify(result, null, 2))

Its tests cover immediate results, multiple HTTP 200 processing responses, terminal failures, 401, 429, 502 and deadline expiry. The example deliberately surfaces failures to the caller so your application can decide which ones to retry.

Keep the response type explicit

The public job endpoint returns canonical timestamped segments when complete. It does not reapply text or chunkSize from the initial request. Join the returned segment text locally if you need one string. A completed response also has jobId, so use content to recognize a ready transcript.

Immediate responses support text=true for one string, or timestamped segments by default. Segment offsets and durations are milliseconds. chunkSize groups whole segments using a character budget from 50 to 10,000; an individual source segment can exceed that budget. text=true takes precedence over chunkSize.

With the SDK quickstart, place this conversion inside the try block after waiting. With the HTTP example, use the returned result directly.

Plain text after waiting
const plain = typeof result.content === "string"
  ? result.content
  : result.content.map((segment) => segment.text).join(" ")
console.log(plain)

The response also identifies lang, availableLangs, source, cached and requestId. Language is a preference and can fall back to an available track. Check the returned language if your downstream model requires a specific one.

For quote links or video editing, retain timing fields instead of concatenating text too early. For a text-only knowledge base, request plain text and store the video ID beside your derived document.

Preserve the job and retry only when useful

Store an accepted job ID before handing work to another process. Polling an existing job does not reserve another transcript request unit. Repeating the original endpoint is a new request and can consume another unit even when its result is cached.

Separate invalid input and credentials from temporary upstream failures. A 429 can mean a short rate limit or an exhausted monthly allowance, so use the error code. Apply bounded backoff to transient cases and stop on failed or cancelled jobs.

Aborting your client request stops waiting; it does not cancel a server job already accepted. If a serverless function has a short execution limit, return or persist the job ID and poll from another invocation instead of keeping the original function alive. See the generated-job guide for that pattern.