YouTube transcript in n8n

n8n has a YouTube node, but it wraps the Data API, and the Data API will not hand you captions for a video you do not own. The usual workaround — a Code node that scrapes the watch page — works on your laptop and dies on the server with RequestBlocked a few days after you deploy it. This page replaces both with one HTTP Request node against https://api.transcriptapi.io/transcript.

Configure the HTTP Request node

Add an HTTP Request node (version 4 or later) and set the following. Everything else stays at its default.

FieldValue
MethodGET
URLhttps://api.transcriptapi.io/transcript
AuthenticationGeneric Credential Type → Header Auth. Create a credential with Name Authorization and Value Bearer ta_your_key. Do not paste the key into the node itself; the credential stays out of exports.
Send Query ParametersOn. video_id = {{ $json.videoId }} (Expression mode), language = en. Optional: translate_to = de.
Options → Response → Response FormatJSON (Autodetect also works)

The response is JSON with a transcript array; each item carries start and duration in seconds plus the text. There is no plain-text format on this endpoint, which is why the workflow ends with a Code node that joins the segments.

what the node receives
{
  "video_id": "dQw4w9WgXcQ",
  "transcript": [
    { "start": 0.0,  "duration": 3.52, "text": "We're no strangers to love" },
    { "start": 3.52, "duration": 2.88, "text": "You know the rules and so do I" }
  ]
}

Full parameter reference, including language fallback behaviour, is in the API docs.

Paste-ready workflow JSON

Copy the block below, open a new workflow in n8n, press Ctrl/Cmd+V on the canvas, then open the TranscriptAPI node and pick your Header Auth credential. Five nodes: Manual Trigger → Set (the video URL) → Code (extract the 11-character id) → HTTP Request → Code (join segments into one text field).

workflow.json
{
  "name": "YouTube transcript to plain text",
  "nodes": [
    {
      "parameters": {},
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "url",
              "name": "url",
              "type": "string",
              "value": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
            }
          ]
        },
        "options": {}
      },
      "name": "Video URL",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Code node · Mode: Run Once for All Items\nconst url = String($input.first().json.url || '');\nconst m = url.match(/(?:v=|youtu\\.be\\/|\\/shorts\\/|\\/embed\\/|\\/live\\/)([A-Za-z0-9_-]{11})/);\nif (!m) throw new Error('No YouTube video id in: ' + url);\nreturn [{ json: { url, videoId: m[1] } }];"
      },
      "name": "Extract video id",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ]
    },
    {
      "parameters": {
        "url": "https://api.transcriptapi.io/transcript",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "video_id",
              "value": "={{ $json.videoId }}"
            },
            {
              "name": "language",
              "value": "en"
            }
          ]
        },
        "options": {}
      },
      "name": "TranscriptAPI",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Code node · Mode: Run Once for All Items\nconst body = $input.first().json;\nconst segments = body.transcript || [];\nconst text = segments\n  .map((s) => s.text.trim())\n  .filter(Boolean)\n  .join(' ');\nreturn [{ json: { videoId: body.video_id, segments: segments.length, text } }];"
      },
      "name": "Join segments",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ]
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Video URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Video URL": {
      "main": [
        [
          {
            "node": "Extract video id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract video id": {
      "main": [
        [
          {
            "node": "TranscriptAPI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "TranscriptAPI": {
      "main": [
        [
          {
            "node": "Join segments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

The two Code nodes are short enough to read here. The first accepts anything a person pastes — watch URLs with tracking parameters, youtu.be links, Shorts, embeds, live links — and fails loudly if there is no id in it. The second turns the segments into prose for whatever comes next.

Extract video id · Code node
// Code node · Mode: Run Once for All Items
const url = String($input.first().json.url || '');
const m = url.match(/(?:v=|youtu\.be\/|\/shorts\/|\/embed\/|\/live\/)([A-Za-z0-9_-]{11})/);
if (!m) throw new Error('No YouTube video id in: ' + url);
return [{ json: { url, videoId: m[1] } }];
Join segments · Code node
// Code node · Mode: Run Once for All Items
const body = $input.first().json;
const segments = body.transcript || [];
const text = segments
  .map((s) => s.text.trim())
  .filter(Boolean)
  .join(' ');
return [{ json: { videoId: body.video_id, segments: segments.length, text } }];

Swap the Manual Trigger for a Form Trigger, a Webhook node or a Google Sheets trigger and the rest of the workflow does not change. If items arrive in batches, the HTTP Request node runs once per item, and each distinct video costs one credit.

The classic mistake: sending the expression itself

The most common n8n support question we get is a 422 that looks like this:

422 response
HTTP 422
{
  "detail": "video_id still contains an unsubstituted template placeholder ('{{ $json.videoId }}'). Your automation sent the variable rather than its value — check the expression that builds this request."
}

What happened: the video_id field was typed in Fixed mode, so n8n sent the literal string {{ $json.videoId }}. Hover the field and click Expression; the value turns green and the preview below it shows the resolved id. We check every id for template markers ({{, ${, {%) before fetching anything, so a placeholder never reaches YouTube and is never charged — but it does stop your workflow.

Also 422, also free

A full URL in video_id fails the same way: "is not a YouTube video ID. Expected the 11-character id from a watch URL". Keep the extraction Code node in front of the request, or point the expression at the trigger field that already holds the id.

Handling refunded failures

Some videos have no transcript to give: captions switched off, private, deleted, age-restricted. Those return 404, the credit is refunded, and the body tells you whether trying again could help. It cannot, and the field to branch on is retryable.

404 response, refunded
HTTP 404
{
  "detail": "This video has captions turned off, so there is no transcript to fetch. Your credits have been refunded.",
  "error": "TranscriptsDisabled",
  "hint": "Retrying will not help — the uploader has disabled captions for this video.",
  "retryable": false,
  "credits_refunded": true
}

By default the HTTP Request node throws on any non-2xx status and stops the execution. That is fine for a one-off. For a workflow that processes a list, turn on Options → Response → Include Response Headers and Status and Never Error, then route with an IF node:

IF node conditions
// IF node, after the HTTP Request node with
// Options → Response → "Include Response Headers and Status" = on
// and "Never Error" = on

// Output 1 (true): a transcript came back
{{ $json.statusCode === 200 }}

// Output 2: the video itself is the problem — do not retry
{{ $json.statusCode !== 200 && $json.body.retryable === false }}

// Anything else (502/503): transient. Wait node, then loop back.

502 and 503 mean YouTube refused the request at that moment; both are refunded and worth one retry after a short Wait node. Alternatively, enable Settings → Retry On Fail on the HTTP Request node with a 10-second wait — it retries 404s too, which is harmless because they cost nothing, just slower. 402 means the balance is empty; the workflow should stop and tell you, not loop.

Recipe: every new video on a channel → summary

The workflow people actually want: a channel publishes, the transcript is fetched, a model summarises it, the summary lands in Slack, Notion or a spreadsheet. Two ways to trigger it.

Option A — poll with Schedule Trigger

  • Schedule Trigger, every hour.
  • HTTP Request · GET https://api.transcriptapi.io/channel/videos with query channel_id = @handle (or a UC… id) and limit = 5. One credit per poll; cached for six hours on our side.
  • Split Out on the videos field.
  • Remove Duplicates→ "Remove Items Processed in Previous Executions", keyed on id. This is what stops you summarising the same video 24 times a day.
  • HTTP Request · the transcript node from above, with video_id = {{ $json.id }}.
  • Join segments Code node, then the OpenAI or Anthropic node with a prompt such as "Summarise in five bullets with the timestamp where each point is made" and {{ $json.text }} as the input.
  • Slack / Notion / Google Sheets node to deliver it.

Option B — let us call you

Add a Webhook trigger node (method POST, path yt-new-video), activate the workflow, and subscribe the channel to its production URL:

subscribe once, from your terminal
curl -X POST "https://api.transcriptapi.io/channel/subscribe" \
  -H "Authorization: Bearer $TA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"channel_id":"UC_x5XG1OV2P6uZZ5FSM9Ttw","webhook_url":"https://your-n8n.example/webhook/yt-new-video"}'

We check the channel every 15 minutes and POST this to n8n when something new appears. The transcript node reads {{ $json.body.video_id }} and the rest of the workflow is identical to option A, minus the polling nodes.

webhook payload
POST https://your-n8n.example/webhook/yt-new-video
{
  "channel_id": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
  "video_id": "kN_iMEAi1dw"
}
Two honest caveats

Delivery is best-effort: no retry, no signature. Put a secret in the webhook path and de-duplicate on video_id. And auto-generated captions can trail an upload by minutes to hours, so a brand-new video may return NoTranscriptFound — refunded, and worth a Wait node of 20 minutes before the retry. The subscription itself is free; see the cookbook for the same receiver in FastAPI and Express.

What it costs

One credit per transcript, one per channel listing, nothing for a cached repeat, nothing for a refunded failure, nothing for the webhook. Hourly polling of one channel is 24 credits a day, about 720 a month, plus one per new video. Translation adds one credit per 40 segments, minimum two.

Every account starts with 20 free credits and no card. After that, 2,500 credits are $10 as a one-time top-up that never expires, and the Production plan is $49 a month for 25,000. Details on the pricing page.

What we do not do: download the video or audio, return comments, or manage a channel you own. If the video has no caption track at all, there is nothing to return and you are not charged.

Frequently asked questions

Is there a native YouTube transcript node in n8n?

No. n8n's YouTube node wraps the YouTube Data API, which cannot download captions for videos you do not own. The HTTP Request node pointed at TranscriptAPI is the whole integration: one GET, one header, JSON back.

Why does the HTTP Request node return 422 'unsubstituted template placeholder'?

Because the literal text {{ $json.videoId }} reached the API instead of the id. The query parameter field was left in Fixed mode, so n8n did not evaluate it. Switch the field to Expression mode. TranscriptAPI rejects placeholder strings before touching YouTube, so the call is not charged.

Does it matter whether I run n8n cloud or self-hosted?

No. The request to YouTube is made from TranscriptAPI's side through residential exits, so it does not matter that your n8n instance sits on Hetzner, AWS or a Raspberry Pi. That is also why scraping YouTube from a Code node on a VPS stops working after a few days and this does not.

What does a run cost?

One credit per transcript. A repeated request for the same video is served from cache and costs nothing, and a failed video (captions off, private, deleted, age-restricted) is refunded automatically. Every account starts with 20 free credits; 2,500 more cost $10 and never expire.

Can I get SRT or VTT out of the workflow instead of plain text?

The endpoint returns JSON segments with start and duration in seconds, so a Code node can format SRT or VTT in a dozen lines, and the cookbook has a ready snippet. If all you need is a subtitle file for one video, the free browser tool at /tools does it without n8n.

Can I translate the transcript in the same request?

Yes. Add a translate_to query parameter with a language code or name (de, German). Translation costs one extra credit per 40 segments, minimum two, and the response carries a translated_to field so a later node can tell which language it got.

Same integration in Make.com and Zapier; in code, see LangChain. If your assistant should fetch transcripts directly, the MCP server does that without a workflow at all. Get a free key with 20 credits at /login.