New: AI voiceover + auto-captions - script to captioned video in one API call. Read the announcement

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 + 3,500 extra$99 + $280

Extra credits on Business are $0.08 each, so a 5,000-lesson library runs about $379, or 7.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.