How-to

Notion to Video: Automate Video Creation from a Database

Turn Notion database pages into finished videos. Connect an integration, unwrap the typed properties into template variables, and render every row automatically.

Notion to Video: Automate Video Creation from a Database

A Notion database page becomes a video when each property maps to a template variable and each row becomes one request. The work is not the render call - it is unwrapping Notion's typed property values into flat strings. Setup takes about 40 minutes, and a new row then produces a new video.

The finished video

This guide renders Teach One Thing Well - a 43-second 16:9 lesson explainer for course libraries and internal training. Eight fields change per row: topic_title, subtitle, point_1 to point_3, takeaway, instructor_name and brand_logo. A Notion database of lessons maps onto it almost column for column. Watch the output on the template page first.

What you need

  • A Notion database with one page per video you want.
  • A Notion internal integration token, from notion.so/my-integrations.
  • A Renderly API key. Create one in the dashboard under Settings → API keys.

Step 1 - Prepare the Notion database

Add one property per variable you want to change per video. Name them after the template variables, so Step 3 stays a straight lookup.

Topic (title)Subtitle (text)Point 1 (text)Status (select)Video (url)
Set up Single Sign-OnConnect your identity provider.Add your IdP's metadata URL.pending
Invite your first teamGet everyone in on day one.Open Settings → Members.pending

Use the plain Text type for anything that becomes on-screen copy. Formula and rollup properties are read-only through the API and arrive in their own shapes, which makes Step 3 longer for no benefit.

For images, paste an external link rather than uploading the file. That choice matters more than it looks, and Step 3 explains why.

Step 2 - Connect an integration and find the data source ID

Create an internal integration at notion.so/my-integrations and copy the token. Then open the database, use the ••• menu, choose Add connections, and select the integration. Skipping this returns a 404 on every call, not a 403 - the database is simply invisible to the token.

Notion's 2025-09-03 API version put a data source between a database and its pages, and queries now run against the data source. Fetch the database once to find it:

curl https://api.notion.com/v1/databases/$NOTION_DATABASE_ID \
  -H "Authorization: Bearer $NOTION_TOKEN" \
  -H "Notion-Version: 2025-09-03"

The response carries a data_sources array. A normal database has exactly one entry; take its id and store it. A database ID cannot be used where a data source ID is expected.

Step 3 - Map Notion properties to template variables

Renderly templates mark replaceable elements with isDynamic: true, and the name of each dynamic overlay is the key you send in the replacements object. Text and shape overlays take content; image, video and sound overlays take src.

Notion never hands you a flat value. Every property is a typed object, so each type needs its own unwrap:

function plain(prop) {
  switch (prop?.type) {
    case "title":     return prop.title[0]?.plain_text ?? "";
    case "rich_text": return prop.rich_text[0]?.plain_text ?? "";
    case "select":    return prop.select?.name ?? "";
    case "number":    return String(prop.number ?? "");
    case "url":       return prop.url ?? "";
    // A file uploaded to Notion expires. An external link does not.
    case "files":     return prop.files[0]?.external?.url ?? "";
    default:          return "";
  }
}

That last line is the one that bites. A file hosted by Notion comes back as files[0].file.url with an expiry_time one hour ahead. Rendering is queued, so a busy queue can reach the job after the link is dead, and the overlay silently keeps its default. An external link comes back as files[0].external.url and never expires. Host images anywhere that serves the file directly and paste the URL in.

Step 4 - Send one render request per row

Query the data source for pending pages, claim each one, then render it:

const NOTION = { Authorization: `Bearer ${process.env.NOTION_TOKEN}`,
  "Notion-Version": "2025-09-03", "Content-Type": "application/json" };
 
const query = await fetch(
  `https://api.notion.com/v1/data_sources/${DATA_SOURCE_ID}/query`,
  { method: "POST", headers: NOTION,
    body: JSON.stringify({
      filter: { property: "Status", select: { equals: "pending" } },
    }) },
).then((r) => r.json());
 
for (const page of query.results) {
  const p = page.properties;
  const replacements = {
    topic_title: plain(p.Topic),
    subtitle: plain(p.Subtitle),
    point_1: plain(p["Point 1"]),
    point_2: plain(p["Point 2"]),
    point_3: plain(p["Point 3"]),
    takeaway: plain(p.Takeaway),
    instructor_name: plain(p.Instructor),
  };
 
  // Claim the page first, so a mid-run failure cannot double-render it.
  await fetch(`https://api.notion.com/v1/pages/${page.id}`, {
    method: "PATCH", headers: NOTION,
    body: JSON.stringify({ properties: { Status: { select: { name: "rendering" } } } }),
  });
 
  await fetch("https://renderly.video/api/v1/renders", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RENDERLY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      templateId: "teach-one-thing-well",
      replacements,
      webhookUrl: `https://your-app.com/hooks/renderly?page=${page.id}`,
    }),
  });
}

The render request returns a job ID immediately, because rendering is asynchronous. Nothing in the loop waits for a video.

The page ID rides along on the webhookUrl query string. That is the cheapest way to remember which Notion page produced which job without keeping a table of your own.

Step 5 - Write the finished video back to the page

Renderly posts back when the job finishes:

{
  "event": "render.completed",
  "data": {
    "jobId": "cm9x2k4p10001qw8r7a3b2c1d",
    "status": "COMPLETED",
    "outputUrl": "https://cdn.renderly.video/renders/cm9x2k4p10001qw8r7a3b2c1d.mp4",
    "creditsUsed": 1,
    "durationInFrames": 1290,
    "fps": 30
  },
  "timestamp": "2026-08-14T09:12:44.110Z"
}

Read page from the callback URL and PATCH /v1/pages/{page_id} with the Video URL property and a done status. The database is now the single record of what has rendered, which is the reason to keep the pipeline in Notion at all.

Payloads are signed with HMAC-SHA256. Verify the signature before you trust the body - the webhooks guide covers the check.

What this costs at scale

Renderly bills 1 credit per minute of 1080p output, rounded up to the nearest half credit. Teach One Thing Well runs 43 seconds, so each lesson costs 1 credit.

LessonsCreditsPlanCost
100100Creator, $29/moincluded
1,0001,000Business, $99/moincluded
5,0005,000Business + 4,000 extra$99 + $480

Extra credits on Business are $0.12 each, so a 5,000-lesson library runs about $579, or 11.6 cents per finished video. Watch the rounding at volume: anything up to 30 seconds bills half a credit, so a tighter cut halves the bill on every row.

Where to go next

Only Steps 1 to 3 change for a different source. The render call is identical, which is why the Airtable guide, the Google Sheets guide and the CSV guide all share Step 4 with this one. Notion costs the most work of the four, because it is the only one whose values arrive typed.

For the pattern behind the whole approach - one design, many payloads - read dynamic video templates.

Frequently asked

Why does my query return a 404 when the database exists?
The database is not shared with the integration. An integration token grants no access on its own. Open the database, use the ••• menu, choose Add connections, and pick your integration. Notion returns 404 rather than 403 for anything an integration cannot see.
Do I query the database ID or the data source ID?
The data source ID, on API version 2025-09-03. A database is now a parent of one or more data sources, and POST /v1/data_sources/{id}/query replaced the old database query endpoint. The two IDs are not interchangeable, so fetch the database once to discover its data source.
Why do images uploaded to Notion break in the finished video?
A file hosted by Notion returns a temporary URL that is valid for one hour. Rendering is queued, so the link can expire before the renderer fetches it. Paste an external link into the property instead, which Notion stores as a permanent URL and returns unchanged.
Can I do this without writing code?
Yes. Notion has a database automation that can send a webhook when a page enters a status, and Zapier, Make.com and n8n all have Notion triggers plus an HTTP request action. Code earns its place when you need the property unwrapping in Step 3 to handle every type.
What happens if a property is empty?
The overlay keeps the default value stored in the template and the render succeeds. Nothing fails on a missing field. Notion also returns an empty array rather than null for an untouched title or rich text property, so unwrap defensively and skip empty values instead of sending an empty string.
How do I stop the same page rendering twice?
Set the status property to rendering before you send the request, not after. The query filters on the pending status, so a page already claimed is excluded from the next run even if the script fails partway through. This is the same pattern the Airtable and Sheets guides use.