How to get a YouTube transcript into Notion
For one video, copy the transcript from VidWords with "Timestamps" or "Text only" selected and paste it into a Notion page. For many videos, a short script can call the VidWords API and create one Notion page per video through Notion's API. VidWords has no built-in Notion integration. It gives you the transcript as copied text or as a TXT, SRT, VTT, CSV or JSON file, and Notion takes it from there. A script has to respect two Notion limits: 2,000 characters per text object, and 100 blocks per request.
One video: copy and paste
- Open
https://vidwords.com/?v=VIDEO_ID, or paste the link into vidwords.com. - Next to Copy, choose Timestamps or Text only, then click Copy.
- Paste into a Notion page.
What lands in Notion depends on that choice. In the default Paragraphs layout, VidWords merges YouTube's short caption lines into paragraphs. It starts a new paragraph at each chapter, or once a paragraph has run for about 25 seconds of video. With Timestamps, each paragraph starts with a single [m:ss]; with Text only, the timestamps go and the paragraph breaks stay. If the video has chapters, each one is preceded by a ## Chapter title line, and Notion turns those into headings when you paste. Switch the layout to Lines if you want one line per caption instead.
Copying from YouTube's own transcript panel gives you a timestamp and a short fragment of text on alternating lines every few seconds, which is the reason to go through a tool at all.
One video: import a file
If you would rather keep a file, download the .txt (with timestamps) or .txt, text only version and bring it in with Notion's Import option, choosing Text & Markdown. Notion creates a page from the file.
The CSV export is a different shape: one row per caption line, with start, duration and text columns. Imported into Notion, that becomes a database with a row for every few seconds of speech. That is useful for searching quotes across a transcript, but it is not a page of notes. SRT and VTT are subtitle formats for video players and have no use in Notion.
A video library in Notion
If you save videos regularly, a database keeps them sortable. A layout that works well:
- Name (title): the video title.
- URL (URL): the YouTube link.
- Channel (text): the creator.
- Status (select) and Tags (multi-select): whatever your workflow needs.
Inside each video's page, put your own notes at the top and the transcript underneath, inside a toggle (type > and a space at the start of a line). Notes stay readable and the full text is one click away when you need an exact quote. A link of the form https://www.youtube.com/watch?v=VIDEO_ID&t=125s opens the video at 2:05, so a timestamp in your notes can point straight at the moment.
The script below creates pages in exactly this database, using the first three properties.
Many videos: VidWords API to Notion API
This script fetches transcripts from VidWords and creates one Notion page per video. Each paragraph starts with a timestamp that links to that second of the video. Setup takes four steps:
- Create an API token on VidWords' API & MCP page.
- Create an internal integration in Notion's integration settings and copy its token.
- Connect the integration to your database from the database's ••• menu, under Connections. Without this step, Notion answers
object_not_found. - Copy the database's data source ID: open the database settings, go to Manage data sources, and choose Copy data source ID.
import os
import time
import requests
VIDWORDS_TOKEN = os.environ["VIDWORDS_TOKEN"]
NOTION_TOKEN = os.environ["NOTION_TOKEN"]
DATA_SOURCE_ID = os.environ["NOTION_DATA_SOURCE_ID"]
NOTION = "https://api.notion.com/v1"
NOTION_HEADERS = {
"Authorization": f"Bearer {NOTION_TOKEN}",
"Notion-Version": "2026-03-11",
"Content-Type": "application/json",
}
TEXT_LIMIT = 1900 # Notion allows 2,000 characters per text object
BLOCKS_PER_CALL = 100 # and 100 blocks per request
def fetch_transcripts(ids):
r = requests.post(
"https://vidwords.com/api/transcripts",
headers={"Authorization": f"Basic {VIDWORDS_TOKEN}"},
json={"ids": ids}, # up to 50 ids or URLs per call
timeout=300,
)
r.raise_for_status()
return r.json()["results"]
def clock(seconds):
s = int(seconds)
h, m, s = s // 3600, s % 3600 // 60, s % 60
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def blocks_for(video_id, segments, every=25):
"""One paragraph per ~25 seconds of speech, each opening with a timestamp link."""
groups = []
for seg in segments:
if not groups or seg["start"] - groups[-1][0] >= every:
groups.append([seg["start"], seg["text"]])
else:
groups[-1][1] += " " + seg["text"]
blocks = []
for start, text in groups:
link = f"https://www.youtube.com/watch?v={video_id}&t={int(start)}s"
for i in range(0, len(text), TEXT_LIMIT):
rich = [{"type": "text", "text": {"content": text[i:i + TEXT_LIMIT]}}]
if i == 0:
stamp = {"type": "text", "text": {"content": f"[{clock(start)}] ", "link": {"url": link}}}
rich.insert(0, stamp)
blocks.append({"object": "block", "type": "paragraph", "paragraph": {"rich_text": rich}})
return blocks
def notion(method, path, body):
for _ in range(3):
r = requests.request(method, f"{NOTION}{path}", headers=NOTION_HEADERS, json=body, timeout=60)
if r.status_code != 429:
break
time.sleep(float(r.headers.get("Retry-After", "1"))) # rate limited: wait and retry
if not r.ok:
raise RuntimeError(f"Notion {r.status_code}: {r.text}")
time.sleep(0.35) # stay under Notion's average of 3 requests a second
return r.json()
def save(item):
vid = item["id"]
blocks = blocks_for(vid, item["segments"])
properties = {
"Name": {"title": [{"text": {"content": (item.get("title") or vid)[:TEXT_LIMIT]}}]},
"URL": {"url": f"https://www.youtube.com/watch?v={vid}"},
}
if item.get("author"):
properties["Channel"] = {"rich_text": [{"text": {"content": item["author"][:TEXT_LIMIT]}}]}
page = notion("POST", "/pages", {
"parent": {"type": "data_source_id", "data_source_id": DATA_SOURCE_ID},
"properties": properties,
"children": blocks[:BLOCKS_PER_CALL],
})
for i in range(BLOCKS_PER_CALL, len(blocks), BLOCKS_PER_CALL):
notion("PATCH", f"/blocks/{page['id']}/children", {"children": blocks[i:i + BLOCKS_PER_CALL]})
return page["url"]
for item in fetch_transcripts(["dQw4w9WgXcQ", "https://youtu.be/jNQXAC9IVRw"]):
if item.get("error"):
print(item["id"], "skipped:", item["error"], item.get("message", ""))
continue
print(item["id"], "->", save(item))
Why it is built this way:
- Text is split at 1,900 characters. Notion rejects any single text object over 2,000 characters, and a paragraph of continuous speech can exceed that.
- Blocks go in batches of 100. Creating a page accepts at most 100 child blocks, and so does each append call. A 25-second paragraph means 100 blocks cover roughly 40 minutes of video; anything longer is appended in further calls.
- Calls are paced. Notion allows an average of three requests a second on most plans and answers
429with aRetry-Afterheader when you go over. - It uses a data source ID. Since API version 2025-09-03, Notion databases can hold several data sources, and its documentation creates database pages with a
data_source_idparent. The script sendsNotion-Version: 2026-03-11, which Notion lists as the latest version.
These limits come from Notion's request-limits and append-block-children reference pages, checked on October 9, 2026.
Troubleshooting
| Notion answers | Cause | Fix |
|---|---|---|
404 object_not_found | The integration is not connected to the database, or the ID is wrong | Database ••• menu, then Connections; re-copy the data source ID |
400 validation_error mentioning a length of 2000 | A text object is too long | Split text below 2,000 characters |
400 validation_error about the number of children | More than 100 blocks in one request | Send the rest with append calls |
400 validation_error naming a property | The database has no property by that name or type | Match Name, URL and Channel exactly, or edit the script |
429 rate_limited | Too many requests | Wait for Retry-After; keep the pause between calls |
401 unauthorized | Wrong or revoked Notion token | Copy the integration token again |
On the VidWords side, a video that cannot be read comes back as an item with an error code, and the script skips it. 403 email_unverified means the account's email address has not been confirmed yet.
No-code automation
n8n and Make can run the same flow without code: an HTTP step calls POST https://vidwords.com/api/transcripts, and a Notion step creates the page. The same Notion limits apply, so a transcript longer than 2,000 characters cannot go into a single text property or block. Split it in a code step first, or store the transcript as page content rather than as a property. The integrations guide covers the VidWords half, including error handling and batching.
What VidWords costs for this
- Copy and paste: three transcripts a month without an account; a free account gets 25 a month.
- The API script: one Cloud Request per delivered transcript. Failed lookups are free, and so is re-reading a video you already have. New accounts include 100 one-time Developer Requests for the API and MCP, with no card.
- A whole channel or playlist: Bulk, a paid-plan feature, exports one file per video as a .zip, or a single combined file.
- Notes rather than the raw transcript: an AI Watch analysis of a tutorial can be downloaded as an article. The download is a .zip containing
article.mdand a still image for each step, and Notion can import the Markdown. Watch spends AI Units, so check the price on the launcher before you run it.