<!-- Markdown twin of https://pixelvault.dev/blog/host-notion-api-images -->

[← Back to blog](/blog)

By [PixelVault](/about) August 29, 2026

# 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](https://img.pixelvault.dev/proj_mkoboesmx25d/img_8bmcabgcyzdg.png?w=1400&fmt=auto&q=auto)

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](/blog/host-images-in-resend-emails).)
-   **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.

Sync a page One call

// 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](/blog/mcp-image-hosting) 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](/blog/host-images-from-discord) and [GitHub's private-repo image URLs](/blog/host-images-for-github-readme) — 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](/blog/host-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](/pricing). Start from the [API quickstart →](/docs).
