Desktop Pricing Docs Blog About GitHub View as Markdown (Agent) Get started
Human Agent
← Back to blog

Notion image URLs expire in an hour. Here's how to host them permanently.

You wired Notion up as a CMS, pulled a page through the API, and shipped. The build looked perfect. An hour later every image on the site is a broken box, and the network tab shows 403 from an amazonaws.com host you never configured. Nothing regressed — Notion's file URLs are presigned and short-lived by design. Here's what's actually happening, and the one call that fixes it.

A document page's expiring signed image link on the left being re-hosted into a permanent PixelVault URL through a vault aperture on the right
This hero is hosted on PixelVault itself — a stable img.pixelvault.dev URL, the same kind this post shows you how to get for a Notion image.

Why Notion image URLs break

Pull an image block out of the Notion API and look at what comes back. For a Notion-hosted file you get a short-lived signed URL — typically an S3 presigned link, something like:

https://prod-files-secure.s3.us-west-2.amazonaws.com/…?X-Amz-Algorithm=…&X-Amz-Expires=3600&X-Amz-Signature=…

Don't key off that hostname, though — Notion has served files from more than one, and its own reference shows a different S3 form. The contractual part is in the response body: alongside url, Notion returns an expiry_time timestamp, and the docs are explicit that the URL "is valid for one hour. If the link expires, send an API request to get an updated URL." Notion keeps uploads in a private bucket and mints a fresh signature each time you read the block; once it lapses, the storage host rejects the request and you get a 403.

Inside Notion you never notice, because the app re-reads the block — and therefore re-signs the URL — every time you open the page. Anything outside Notion never gets that refresh. The URL you baked into your HTML at build time is a snapshot of a credential that has already expired by the time a reader loads the page.

What this actually breaks

The failure is delayed, which is what makes it so confusing — it always works when you test it, and it's always broken when someone else looks. In practice it bites:

  • Static site builds. Notion → Astro, Next, Hugo, Eleventy. The URL is frozen into the generated HTML and dies an hour after the build, not an hour after the visit.
  • RSS feeds and newsletters. The image was fine when the feed was generated and dead by the time it's opened — and an email lives in an inbox for years. (Same reason images in transactional email need their own host.)
  • Open Graph and social cards. Scrapers fetch the URL later, on their own schedule, and cache a broken result.
  • Docs sites and wikis that mirror Notion content into another surface.
  • Agents and LLM ingestion. Anything that crawls your content and stores an image reference is storing a dead link.

Notion's own answer, and where it stops

Notion's documented guidance is to re-fetch the page to get a fresh URL. That's a real fix for a live application — if your app queries Notion on every request, the signed URL it hands the browser is always minutes old and it works fine. Credit where it's due: for a server-rendered dashboard reading Notion in real time, this is genuinely enough, and you can stop here.

It stops working the moment the URL outlives the request that produced it. A static build, a sent email, a cached CDN response, an OG scrape — none of those can go back and ask Notion for a new signature. You'd be re-fetching on a cron and rewriting published HTML, which is a lot of machinery for what is really a storage problem.

There is one more native option worth knowing: Notion distinguishes file images (uploaded to Notion, signed, expiring) from external images (a URL you supply, passed through untouched). Notion passes external URLs through untouched — it doesn't wrap them in a one-hour signature, because it isn't hosting the bytes. (Their durability is then whatever the host you pointed at provides, which is the point: pick one you trust.) That's the actual shape of the fix — you just need a URL to point at.

The fix: re-host once, at build time

Fetch each image while its signature is still valid, store it somewhere you control, and use that URL from then on. PixelVault does this in a single call: hand it the signed Notion URL and it fetches the bytes server-side, so you never download or buffer the image in your own build. What comes back is a permanent, immutable CDN URL.

// Build step: swap Notion's 1-hour signed URLs for permanent ones.
import { Client, isFullBlock } from "@notionhq/client";

const notion = new Client({ auth: process.env.NOTION_TOKEN });
const pageId = process.env.NOTION_PAGE_ID;
const hosted = {}; // block id → permanent URL

async function rehost(sourceUrl) {
  // PixelVault fetches the signed URL server-side, while it's still valid.
  const res = await fetch("https://api.pixelvault.dev/v1/images", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PIXELVAULT_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ url: sourceUrl, folder: "notion" }),
  });
  if (!res.ok) throw new Error(`Re-host failed: ${res.status}`);
  return (await res.json()).data.url;
}

// Children come back 100 at a time, and images nest inside columns,
// toggles and callouts — so page through, and recurse.
async function walk(blockId) {
  let cursor;
  do {
    const page = await notion.blocks.children.list({
      block_id: blockId,
      start_cursor: cursor,
    });
    for (const block of page.results) {
      if (!isFullBlock(block)) continue;
      // "file" = Notion-hosted, signed + expiring (see file.expiry_time).
      // "external" = a URL you already own, left alone.
      if (block.type === "image" && block.image.type === "file") {
        hosted[block.id] = await rehost(block.image.file.url);
      }
      if (block.has_children) await walk(block.id);
    }
    cursor = page.next_cursor ?? undefined;
  } while (cursor);
}

await walk(pageId);
console.log(hosted); // → { "<block id>": "https://img.pixelvault.dev/…" }

Run that as part of your build, or once per page when content changes, and cache the result — the returned URL is stable, so there's no reason to re-host the same image on every build. Passing folder keeps a Notion sync tidily separated from the rest of your images.

Two things the snippet is deliberately explicit about, because both are easy to get wrong and fail silently: children come back 100 at a time, so you have to follow next_cursor, and images nest inside columns, toggles and callouts, so you have to recurse on has_children. Skip either and you'll re-host some images, ship a build that still has broken ones, and have no obvious signal that anything was missed. It covers image blocks; page covers and files properties on database rows are the same file/external shape and re-host the same way. (This is plain JS — in TypeScript, isFullBlock is also what narrows results so block.type typechecks.)

If you want the permanent URL to live in Notion too, append a new external image block or store it in a URL property — Notion passes those through untouched, so there's no signature to expire.

The timing caveat

This only works while the signed URL is still alive. A Notion URL you saved in a database last week is already dead, and no service can re-host bytes it can't fetch — if a tool claims otherwise, be suspicious. The fix is always to re-read the block from the Notion API first, which mints a fresh signature, and re-host from that. Your content is safe in Notion; it's only the link that's perishable.

Two practical limits worth knowing up front: uploads cap at 5 MB per image, and the URL import path requires an API key (a keyless request can upload a file, but won't fetch an arbitrary URL for you).

Notion file URLs vs a URL you own

Notion API file URL
PixelVault
Lifetime
1 hour (X-Amz-Expires)
Permanent
Safe in a static build
No — expires post-deploy
Yes
Safe in an email or feed
No
Yes
Safe to store in a DB
No — re-read to refresh
Yes
Resize / convert on the fly
No
?w=, fmt=auto
Cost to serve at scale
n/a (breaks first)
Zero egress

If an agent is doing the sync

Notion-to-site pipelines are increasingly written and run by coding agents, and this is one of those bugs an agent will otherwise ship straight past — the build succeeds, so nothing looks wrong. Connect the PixelVault MCP server and upload_image takes a source_url, so the agent can re-host a signed Notion URL as a single tool call without ever pulling the bytes into its context.

This is the same failure mode as Discord's expiring attachment links and GitHub's private-repo image URLs — a signed, short-lived CDN link doing a job that needs a permanent one. If you're generating the images rather than storing them, hosting AI-generated images covers that flow.

Free to start

PixelVault's free tier includes 200 MB storage, 500 uploads/month, and 1 GB bandwidth — no credit card, zero egress. Enough to back a Notion-powered blog or docs site before you pay anything, and paid plans start at $9/month. Start from the API quickstart →.