AutomationFlowsGeneral › Daily YouTube Channel Sync to Notion

Daily YouTube Channel Sync to Notion

Original n8n title: Youtube Channel to Notion

YouTube Channel to Notion. Uses stickyNote, scheduleTrigger, httpRequest, noOp. Scheduled trigger; 18 nodes.

Cron / scheduled trigger★★★★☆ complexity18 nodesHTTP Request
General Trigger: Cron / scheduled Nodes: 18 Complexity: ★★★★☆ Added:

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "YouTube Channel to Notion",
  "nodes": [
    {
      "parameters": {
        "content": "## YouTube Channel to Notion\n\nSchedule trigger fires daily 04:00 UTC. Workflow fetches the YouTube RSS feed for each channel ID in `YOUTUBE_CHANNEL_IDS` env, parses with a lightweight RSS parser (no external XML lib), filters to videos newer than the last-seen `videoId` per channel, optionally summarizes the title + description via OpenAI / Anthropic, and writes one Notion page per new video into the configured database.\n\n**Production patterns wired:**\n- Rate limit per channel-host (defense for YouTube + Notion APIs)\n- Idempotency on `videoId`\n- Optional LLM summary with multi-provider Switch (OpenAI default, Anthropic optional)\n- Error branch with structured fallback + Slack alert\n\nNo HMAC, this trigger is Schedule, not a public webhook. Uses public YouTube RSS so no API key required for the basic flow.\n\nSee `README.md` for setup, env vars, and extension recipes.",
        "height": 360,
        "width": 400,
        "color": 6
      },
      "id": "note-intro",
      "name": "Sticky Note - Intro",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -200,
        -100
      ]
    },
    {
      "parameters": {
        "content": "### >> SET ME <<\n\n1. Set `YOUTUBE_CHANNEL_IDS` to a comma-separated list of channel IDs (`UCxxxxx...`). Find on the channel page via View Source then ctrl-F `channel_id`.\n2. Set `NOTION_API_TOKEN` (Internal Integration Token from notion.so/my-integrations).\n3. Set `NOTION_DATABASE_ID` (32-char UUID of target database).\n4. Share the Notion database with your integration (top-right Share menu).\n5. Optional LLM summary:\n   - `LLM_SUMMARY_ENABLED=1`\n   - `LLM_PROVIDER=openai` (default) or `anthropic`\n   - matching credentials (`OpenAI API` / `Anthropic API`)\n6. Optional: `MAX_VIDEOS_PER_CHANNEL_PER_RUN=10` (default), `SLACK_OPS_WEBHOOK` for alerts.\n7. Self-hosted n8n: set `NODE_FUNCTION_ALLOW_BUILTIN=crypto`.",
        "height": 360,
        "width": 380,
        "color": 5
      },
      "id": "note-setup",
      "name": "Sticky Note - Setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -200,
        280
      ]
    },
    {
      "parameters": {
        "content": "## Production Patterns\n\nThree opt-in nodes wired plus always-on error branch.\n\n- **Rate limit:** `RATE_LIMIT_ENABLED=1`. Per-host bucket: 30 RSS fetches / hour / host (YouTube cap, generous), 60 Notion writes / 5 min.\n- **Idempotency:** `IDEMPOTENCY_ENABLED=1`. Persistent map `seen[videoId] = lastRunAt` so the same video is never written twice across runs.\n- **Multi-provider Switch (optional LLM):** `LLM_SUMMARY_ENABLED=1` flips the optional summary on. OpenAI default, Anthropic optional.\n- **Error branch:** always on. Notion failure -> Slack alert + structured log.\n- **Hard caps:** `MAX_VIDEOS_PER_CHANNEL_PER_RUN` (default 10) defends against backfill on the first run.\n\nNo HMAC: Schedule trigger, server-internal.",
        "height": 360,
        "width": 380,
        "color": 7
      },
      "id": "note-production-patterns",
      "name": "Sticky Note - Production Patterns",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        840,
        -300
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "triggerAtHour": 4
            }
          ]
        }
      },
      "id": "yt-1-trigger",
      "name": "Schedule (Daily 04:00 UTC)",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        240,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// List the configured channel IDs and emit one item per channel.\n// Read the seen-video state for each so the downstream filter can compare.\n\nconst raw = $env.YOUTUBE_CHANNEL_IDS || '';\nconst channelIds = raw.split(',').map(s => s.trim()).filter(Boolean);\nif (channelIds.length === 0) {\n  throw new Error('YOUTUBE_CHANNEL_IDS env is empty. Set it to a comma-separated list of UC... channel IDs.');\n}\n// Cap at 50 channels per run to keep one execution bounded.\nif (channelIds.length > 50) {\n  throw new Error('YOUTUBE_CHANNEL_IDS exceeds 50 channels. Split across multiple workflow instances if you need more.');\n}\n\nconst data = $getWorkflowStaticData('global');\ndata.seenVideos = data.seenVideos || {};\nconst seen = data.seenVideos;\n\nreturn channelIds.map(id => ({ json: {\n  channelId: id,\n  rssUrl: 'https://www.youtube.com/feeds/videos.xml?channel_id=' + id,\n  seenForChannel: Object.keys(seen).filter(k => seen[k] && seen[k].channelId === id).map(k => k),\n}}));"
      },
      "id": "yt-2-list-channels",
      "name": "List Channels",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Per-host sliding-window rate limit on the RSS fetch + Notion write side, opt-in.\n\nif ($env.RATE_LIMIT_ENABLED !== '1') {\n  return $input.all();\n}\n\nconst LIMIT_PER_HOUR = 30;\nconst WINDOW_MS = 60 * 60 * 1000;\nconst MAX_KEYS = 5000;\n\nconst data = $getWorkflowStaticData('global');\ndata.rateBuckets = data.rateBuckets || {};\nconst buckets = data.rateBuckets;\nconst now = Date.now();\n\nfor (const k of Object.keys(buckets)) {\n  buckets[k] = (buckets[k] || []).filter(t => now - t < WINDOW_MS);\n  if (buckets[k].length === 0) delete buckets[k];\n}\nif (Object.keys(buckets).length > MAX_KEYS) {\n  const oldest = Object.entries(buckets).sort((a, b) => (a[1][0] || 0) - (b[1][0] || 0)).slice(0, 100);\n  for (const [k] of oldest) delete buckets[k];\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n  const j = item.json || {};\n  const key = 'host:youtube.com';\n  const hits = buckets[key] || [];\n  if (hits.length >= LIMIT_PER_HOUR) {\n    out.push({ json: { ...j, skipped: true, reason: 'rate-limit-host' } });\n    continue;\n  }\n  buckets[key] = [...hits, now];\n  out.push(item);\n}\nreturn out;"
      },
      "id": "yt-pp-1-ratelimit",
      "name": "Rate Limit (opt-in)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        640,
        60
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.rssUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "timeout": 10000
        }
      },
      "id": "yt-3-fetch-rss",
      "name": "Fetch YouTube RSS",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        840,
        60
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Lightweight RSS / Atom parser for YouTube feeds.\n// Each YouTube RSS entry has yt:videoId, yt:channelId, title, link, published, description (optional), media:thumbnail.\n//\n// Extracted via regex against the raw XML rather than pulling in a full XML parser.\n// YouTube's feed is well-formed and does not need that level of robustness.\n\nconst items = $input.all();\nconst out = [];\nfor (const item of items) {\n  const j = item.json || {};\n  const xml = (j.data && typeof j.data === 'string') ? j.data : (j.body && typeof j.body === 'string' ? j.body : '');\n  if (!xml) {\n    out.push({ json: { ...j, skipped: true, reason: 'rss-empty' } });\n    continue;\n  }\n  const channelId = j.channelId;\n\n  // Pull entries.\n  const entries = [];\n  const entryRe = /<entry>([\\s\\S]*?)<\\/entry>/g;\n  let match;\n  while ((match = entryRe.exec(xml)) !== null) {\n    const body = match[1];\n    const videoId = (body.match(/<yt:videoId>([^<]+)<\\/yt:videoId>/) || [])[1];\n    if (!videoId) continue;\n    const title = ((body.match(/<title>([\\s\\S]*?)<\\/title>/) || [])[1] || '').replace(/<!\\[CDATA\\[|\\]\\]>/g, '').trim();\n    const link = (body.match(/<link rel=\"alternate\" href=\"([^\"]+)\"/) || [])[1] || ('https://www.youtube.com/watch?v=' + videoId);\n    const published = (body.match(/<published>([^<]+)<\\/published>/) || [])[1] || null;\n    const description = ((body.match(/<media:description>([\\s\\S]*?)<\\/media:description>/) || [])[1] || '').replace(/<!\\[CDATA\\[|\\]\\]>/g, '').trim();\n    const thumbnail = (body.match(/<media:thumbnail url=\"([^\"]+)\"/) || [])[1] || null;\n    const author = ((body.match(/<author>[\\s\\S]*?<name>([^<]+)<\\/name>/) || [])[1] || '').trim();\n\n    entries.push({\n      channelId,\n      videoId,\n      title: title.slice(0, 200),\n      url: link,\n      publishedAt: published,\n      description: description.slice(0, 1800),\n      thumbnail,\n      author: author.slice(0, 100),\n    });\n  }\n\n  // YouTube returns most recent first. Keep that order.\n  for (const e of entries) {\n    out.push({ json: e });\n  }\n}\nif (out.length === 0) {\n  return [{ json: { skipped: true, reason: 'no-entries-parsed' } }];\n}\nreturn out;"
      },
      "id": "yt-4-parse-rss",
      "name": "Parse RSS",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Idempotency on videoId, opt-in. Persistent across runs (long-lived workflow static data).\n// Plus per-channel cap on how many videos to process in one run.\n\nconst MAX_PER_CHANNEL = parseInt($env.MAX_VIDEOS_PER_CHANNEL_PER_RUN || '10', 10);\nconst items = $input.all();\nconst out = [];\n\nif ($env.IDEMPOTENCY_ENABLED !== '1') {\n  // No idempotency, just enforce the per-channel cap.\n  const perChannel = {};\n  for (const item of items) {\n    const j = item.json || {};\n    if (j.skipped) { out.push(item); continue; }\n    const id = j.channelId;\n    perChannel[id] = (perChannel[id] || 0) + 1;\n    if (perChannel[id] > MAX_PER_CHANNEL) continue;\n    out.push(item);\n  }\n  return out;\n}\n\nconst data = $getWorkflowStaticData('global');\ndata.seenVideos = data.seenVideos || {};\nconst seen = data.seenVideos;\nconst now = Date.now();\nconst MAX_KEYS = 50000;\nconst KEEP_DAYS = 90; // remember a videoId for 90 days, then evict\n\nfor (const k of Object.keys(seen)) {\n  if (now - (seen[k].seenAt || 0) > KEEP_DAYS * 24 * 60 * 60 * 1000) delete seen[k];\n}\nif (Object.keys(seen).length > MAX_KEYS) {\n  const oldest = Object.entries(seen).sort((a, b) => (a[1].seenAt || 0) - (b[1].seenAt || 0)).slice(0, 5000);\n  for (const [k] of oldest) delete seen[k];\n}\n\nconst perChannel = {};\nfor (const item of items) {\n  const j = item.json || {};\n  if (j.skipped) { out.push(item); continue; }\n  const id = j.channelId;\n  const vid = j.videoId;\n  if (!vid) continue;\n  if (seen[vid]) continue; // already processed\n  perChannel[id] = (perChannel[id] || 0) + 1;\n  if (perChannel[id] > MAX_PER_CHANNEL) continue; // cap\n  seen[vid] = { channelId: id, seenAt: now };\n  out.push(item);\n}\nif (out.length === 0) {\n  return [{ json: { skipped: true, reason: 'no-new-videos' } }];\n}\nreturn out;"
      },
      "id": "yt-pp-2-idempotency",
      "name": "Idempotency Check (opt-in)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1240,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build the LLM summary prompt if LLM_SUMMARY_ENABLED=1, else mark items as no-summary-needed.\n\nconst summaryEnabled = $env.LLM_SUMMARY_ENABLED === '1';\nconst provider = ($env.LLM_PROVIDER || 'openai').toLowerCase();\n\nconst out = [];\nfor (const item of $input.all()) {\n  const j = item.json || {};\n  if (j.skipped) { out.push(item); continue; }\n  if (!summaryEnabled) {\n    out.push({ json: { ...j, summary: null, summaryProvider: 'none' } });\n    continue;\n  }\n\n  const sourceText = (j.title + '\\n\\n' + (j.description || '')).slice(0, 3000);\n  const systemPrompt = 'Summarize the following YouTube video in 2-3 short sentences. Output ONLY the summary, no preamble. If the description is empty or too short to summarize, return the title verbatim.';\n  out.push({ json: { ...j, provider, systemPrompt, userText: sourceText } });\n}\nreturn out;"
      },
      "id": "yt-5-build-summary-prompt",
      "name": "Build Summary Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1440,
        60
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "rule-skipped",
                    "leftValue": "={{ $json.skipped }}",
                    "rightValue": true,
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "skipped"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "rule-no-summary",
                    "leftValue": "={{ $json.summaryProvider }}",
                    "rightValue": "none",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "no-summary"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "rule-openai",
                    "leftValue": "={{ $json.provider }}",
                    "rightValue": "openai",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "openai"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "rule-anthropic",
                    "leftValue": "={{ $json.provider }}",
                    "rightValue": "anthropic",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "anthropic"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "yt-6-route",
      "name": "Route by Provider",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1640,
        60
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/chat/completions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'gpt-5.4-mini', messages: [{ role: 'system', content: $json.systemPrompt }, { role: 'user', content: $json.userText }], temperature: 0.2, max_tokens: 200 }) }}",
        "options": {}
      },
      "id": "yt-7-openai",
      "name": "OpenAI Summarize",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1840,
        -60
      ],
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 200, system: $json.systemPrompt, messages: [{ role: 'user', content: $json.userText }] }) }}",
        "options": {}
      },
      "id": "yt-8-anthropic",
      "name": "Anthropic Summarize",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1840,
        180
      ],
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Normalize OpenAI vs Anthropic response shape and merge with the upstream video metadata.\n// Items can come from openai-success, anthropic-success, no-summary, or LLM-error path.\n//\n// Metadata-lookup priority (catches the partial-LLM-failure case):\n// 1. pairedItem index against $('Build Summary Prompt').all(): n8n's stable item-tracking\n//    across branches (works even when only a subset of items hit the error pin).\n// 2. In-place $json.videoId: true for the no-summary branch which passes metadata through.\n// 3. Last-resort array-index match: only used when pairedItem missing and $json has no videoId.\n//    This is the legacy fallback that the Critic flagged as fragile, kept here as a final\n//    safety net rather than the primary path.\n\nconst items = $input.all();\nlet upstream = [];\ntry { upstream = $('Build Summary Prompt').all(); } catch (e) { upstream = []; }\n\nconst out = [];\nfor (let i = 0; i < items.length; i++) {\n  const item = items[i];\n  const j = item.json || {};\n  if (j.skipped) { out.push(item); continue; }\n\n  let summary = null;\n  if (j.choices && Array.isArray(j.choices) && j.choices.length > 0) {\n    summary = (j.choices[0].message && j.choices[0].message.content) || '';\n  } else if (j.content && Array.isArray(j.content) && j.content.length > 0) {\n    summary = j.content[0].text || '';\n  } else if (j.summary !== undefined) {\n    summary = j.summary;\n  }\n  if (typeof summary === 'string') summary = summary.trim().slice(0, 1000);\n\n  let videoMeta = null;\n\n  // Tier 1: pairedItem (preserves order across branches and error paths)\n  if (item.pairedItem) {\n    const pIdx = typeof item.pairedItem === 'object'\n      ? (item.pairedItem.item != null ? item.pairedItem.item : null)\n      : (typeof item.pairedItem === 'number' ? item.pairedItem : null);\n    if (typeof pIdx === 'number' && upstream[pIdx] && upstream[pIdx].json && upstream[pIdx].json.videoId) {\n      videoMeta = upstream[pIdx].json;\n    }\n  }\n\n  // Tier 2: in-place metadata (no-summary branch path)\n  if (!videoMeta && j.videoId) {\n    videoMeta = j;\n  }\n\n  // Tier 3: last-resort array-index match (legacy fallback, fragile across error pins)\n  if (!videoMeta && upstream[i] && upstream[i].json && upstream[i].json.videoId) {\n    videoMeta = upstream[i].json;\n  }\n\n  // Fail-safe: if nothing matched, drop with structured warning rather than write garbage to Notion.\n  if (!videoMeta || !videoMeta.videoId) {\n    out.push({ json: { skipped: true, reason: 'metadata-lookup-failed', summary } });\n    continue;\n  }\n\n  out.push({ json: {\n    skipped: false,\n    channelId: videoMeta.channelId,\n    videoId: videoMeta.videoId,\n    title: videoMeta.title,\n    url: videoMeta.url,\n    publishedAt: videoMeta.publishedAt,\n    description: videoMeta.description,\n    thumbnail: videoMeta.thumbnail,\n    author: videoMeta.author,\n    summary,\n  }});\n}\nreturn out;"
      },
      "id": "yt-9-normalize",
      "name": "Normalize Video Item",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2040,
        60
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.notion.com/v1/pages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.NOTION_API_TOKEN }}"
            },
            {
              "name": "Notion-Version",
              "value": "2025-09-03"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ parent: { database_id: $env.NOTION_DATABASE_ID }, properties: { Title: { title: [{ text: { content: $json.title } }] }, Channel: { rich_text: [{ text: { content: $json.author || $json.channelId } }] }, Url: { url: $json.url }, PublishedAt: { date: { start: $json.publishedAt } }, Summary: { rich_text: [{ text: { content: $json.summary || $json.description || '' } }] }, VideoId: { rich_text: [{ text: { content: $json.videoId } }] } } }) }}",
        "options": {}
      },
      "id": "yt-10-notion",
      "name": "Notion Create Page",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2240,
        60
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "respondWith": "noData",
        "options": {}
      },
      "id": "yt-11-done",
      "name": "Done",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2440,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Fallback for any failure (RSS fetch, LLM, Notion).\n// Build a structured error log and post a Slack alert if SLACK_OPS_WEBHOOK is set.\n\nconst input = $input.first();\nconst raw = input.json || {};\nconst errorRaw = raw.error || raw;\nconst isLlmError = !!(errorRaw && (errorRaw.message || errorRaw.code || (errorRaw.error && errorRaw.error.message)));\nlet errorMessage;\nif (isLlmError) {\n  errorMessage = (errorRaw.message) || (errorRaw.error && errorRaw.error.message) || ('Error code ' + (errorRaw.code || '?'));\n} else {\n  errorMessage = 'Unknown error in YouTube-to-Notion sync';\n}\n\nlet stage = 'unknown';\ntry { if ($('Notion Create Page').first()) stage = 'notion'; } catch (e) {}\ntry { if ($('OpenAI Summarize').first()) stage = 'openai'; } catch (e) {}\ntry { if ($('Anthropic Summarize').first()) stage = 'anthropic'; } catch (e) {}\ntry { if ($('Fetch YouTube RSS').first()) stage = 'rss-fetch'; } catch (e) {}\n\nreturn [{ json: { syncError: { message: errorMessage, stage, raw: errorRaw } } }];"
      },
      "id": "yt-err-fallback",
      "name": "Error Fallback",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2240,
        380
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.SLACK_OPS_WEBHOOK }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: ':warning: YouTube-to-Notion sync failed at ' + ($json.syncError.stage || '?') + ': ' + ($json.syncError.message || 'unknown error') }) }}",
        "options": {}
      },
      "id": "yt-err-slack",
      "name": "Slack Alert",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2440,
        380
      ],
      "onError": "continueRegularOutput"
    }
  ],
  "connections": {
    "Schedule (Daily 04:00 UTC)": {
      "main": [
        [
          {
            "node": "List Channels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List Channels": {
      "main": [
        [
          {
            "node": "Rate Limit (opt-in)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rate Limit (opt-in)": {
      "main": [
        [
          {
            "node": "Fetch YouTube RSS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch YouTube RSS": {
      "main": [
        [
          {
            "node": "Parse RSS",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Error Fallback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse RSS": {
      "main": [
        [
          {
            "node": "Idempotency Check (opt-in)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Idempotency Check (opt-in)": {
      "main": [
        [
          {
            "node": "Build Summary Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Summary Prompt": {
      "main": [
        [
          {
            "node": "Route by Provider",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Provider": {
      "main": [
        [],
        [
          {
            "node": "Normalize Video Item",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "OpenAI Summarize",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Anthropic Summarize",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Normalize Video Item",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Summarize": {
      "main": [
        [
          {
            "node": "Normalize Video Item",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Normalize Video Item",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anthropic Summarize": {
      "main": [
        [
          {
            "node": "Normalize Video Item",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Normalize Video Item",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Video Item": {
      "main": [
        [
          {
            "node": "Notion Create Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notion Create Page": {
      "main": [
        [
          {
            "node": "Done",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Error Fallback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Error Fallback": {
      "main": [
        [
          {
            "node": "Slack Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

How this works

Stay effortlessly organised by automatically capturing new videos from your YouTube channel and storing them in a Notion database, saving hours of manual logging each week. This workflow suits content creators, marketers, or anyone monitoring a channel's output for analysis or archiving, ensuring you never miss an upload. The key step involves fetching the channel's RSS feed via HTTP Request and parsing it to extract video details like titles, links, and descriptions before syncing to Notion.

Use this workflow for daily automated updates on personal or watched channels where Notion serves as your central hub for content tracking. Avoid it for high-volume channels exceeding YouTube's API limits, opting instead for direct API integrations to prevent rate throttling. Common variations include adjusting the schedule trigger for hourly runs or adding filters in the code nodes to focus on specific video categories.

About this workflow

YouTube Channel to Notion. Uses stickyNote, scheduleTrigger, httpRequest, noOp. Scheduled trigger; 18 nodes.

Source: https://github.com/studiomeyer-io/n8n-workflows/blob/main/templates/15-youtube-channel-to-notion/workflow.json — original creator credit. Request a take-down →

More General workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

General

WF-Main - XHS 主控制器. Uses scheduleTrigger, httpRequest, executeWorkflow, noOp. Scheduled trigger; 21 nodes.

HTTP Request
General

Dm-Profile-Visitors. Uses httpRequest, googleSheets. Scheduled trigger; 21 nodes.

HTTP Request, Google Sheets
General

RSS to Multi-Channel Social (X / LinkedIn / Discord). Uses stickyNote, scheduleTrigger, httpRequest. Scheduled trigger; 19 nodes.

HTTP Request
General

Automate Droplet Snapshots On Digitalocean. Uses httpRequest, stickyNote. Scheduled trigger; 17 nodes.

HTTP Request
General

Calendar Conflict Detector (Google / Outlook). Uses stickyNote, scheduleTrigger, httpRequest. Scheduled trigger; 16 nodes.

HTTP Request