Notion platform
The HTML block sandbox
What it actually is
When you create an HTML block, Notion stores an .html file in its own file storage and renders it inside a sandboxed frame. The /html slash command does this by hand: you paste source, Notion uploads it. The API does it in three requests: reserve an upload, send the bytes, then append an embed block that points at the upload id.
There is no separate html block type in the API. The embed block type does double duty: with a url it iframes a remote page, with a file_upload.id it renders an uploaded HTML file. This is the part people get wrong, and the reason scripted attempts fail on the first try.
heading_4, tab blocks, and the position field that replaced after.Capability matrix
Documented behaviour, plus what we observe when our own widgets render in a real block. Where a row is browser-dependent rather than Notion-dependent it is marked partial, treat those as "test it on the devices your team actually uses".
| Capability | What it means in practice | |
|---|---|---|
| JavaScript execution | Full ES2022. Timers, requestAnimationFrame, canvas 2D, SVG, WebAudio, drag events, IntersectionObserver, the async Clipboard API on a user gesture. | |
| CSS | Full, including container queries, <code>:has()</code>, <code>color-mix()</code> and custom properties. Inline only, no external stylesheets. | |
| Viewing the block | Every plan: Free, Plus, Business, Enterprise. It is a block, not a feature flag. | |
| localStorage / IndexedDB | Works, scoped to the browser profile. Not synced to teammates, not available in the mobile app's WebView on some versions, cleared with site data. | |
| Clipboard write | Requires a user gesture. Our export buttons therefore always also <code>console.log</code> the payload as a fallback. | |
| External network calls | None. No <code>fetch</code>, no XHR, no WebSocket, no image hotlink, no CDN script, no webfont, no analytics beacon. | |
| Reading the host page | No URL, no <code>location.search</code>, no postMessage bridge to Notion, no access to page content or database properties. | |
| Writing to Notion | No API access. A block cannot create a page, tick a checkbox or update a property, even with a valid token, which it could not hold anyway. | |
| AI generating or editing the HTML | Notion AI can author and revise HTML blocks on Business and Enterprise plans only. Blocks you installed still render everywhere regardless of who made them. | |
| Editing in place | You replace the file. Notion does not expose a source editor for an existing block through the API. |
Configuring a block that has no URL
The hosted embed reads its parameters from the query string. A downloaded HTML block cannot: location.search is empty inside the sandbox. So the configuration has to travel inside the file. Every Notion Arena widget implements the same loader:
// 1. baked-in config, if the file was downloaded with one
const baked = JSON.parse(
document.getElementById("bk-config")?.textContent ?? "{}"
);
// 2. query string on top, if the file is being served at a URL
const qs = new URLSearchParams(location.search);
const cfg = { ...baked };
for (const [key, value] of qs) cfg[key] = value;
// 3. typed accessors: never trust the raw string
const num = (k, d) => Number.isFinite(+cfg[k]) ? +cfg[k] : d;
const bool = (k, d) => cfg[k] === undefined ? d : ["true", "1"].includes(String(cfg[k]));The injection point is a <script> tag placed immediately before </head>, and the values are escaped for <, >, &, U+2028 and U+2029 so a label containing </script> cannot break out of the tag. Only declared parameters survive: unknown keys are dropped rather than passed through, which is what stops a URL from becoming an injection vector.
Two parameters exist on every widget
theme:auto,lightordark. Setsdata-themeon<html>.autofollowsprefers-color-scheme, which inside Notion means it follows Notion's own appearance setting.accent: any CSS colour, written to--accent. Derived shades usecolor-mix(in oklab, …)so one accent produces a consistent palette rather than five hardcoded ones.
State, and why it is not shared
Widgets that need to remember something (a pomodoro's session count, a habit grid's checked squares, a kanban's card positions) use localStorage under a namespaced key:
notion-arena.pomodoro-timer.v1 → {"day":"2026-10-11","sessions":3,"minutes":75}
notion-arena.habit-grid.v1 → {"2026-10-11":{"Read":1,"Run":0}}
notion-arena.whiteboard.v1 → {"shapes":[…],"notes":[…],"pan":[0,0]}The .v1 suffix is deliberate: it lets a widget migrate a stored shape without silently corrupting an older one.
Installing one, end to end
Three requests. The second one goes to a Notion storage host, not to api.notion.com, and takes no Authorization header: the upload URL is already scoped.
set -euo pipefail
NOTION_VERSION="2026-03-11"
# 1 · reserve an upload
RES=$(curl -sS -X POST https://api.notion.com/v1/file_uploads \
-H "Authorization: Bearer $NOTION_TOKEN" \
-H "Notion-Version: $NOTION_VERSION" \
-H "Content-Type: application/json" -d '{}')
UPLOAD_ID=$(echo "$RES" | jq -r .id)
UPLOAD_URL=$(echo "$RES" | jq -r .upload_url)
# 2 · send the bytes to the storage host
curl -sS -X POST "$UPLOAD_URL" \
-H "Content-Type: text/html" \
--data-binary @pomodoro-timer.html
# 3 · append an embed block referencing the upload
curl -sS -X PATCH "https://api.notion.com/v1/blocks/$PAGE_ID/children" \
-H "Authorization: Bearer $NOTION_TOKEN" \
-H "Notion-Version: $NOTION_VERSION" \
-H "Content-Type: application/json" \
-d '{
"children": [{
"object": "block",
"type": "embed",
"embed": {
"file_upload": { "id": "'"$UPLOAD_ID"'" },
"caption": [{ "type": "text", "text": { "content": "Pomodoro" } }]
}
}]
}'Or let this site do it: POST /api/install with mode: "html-block" renders the widget with your config, runs all three requests, and returns the block id.
restricted_resource, your integration is not connected to the target page. On the page: ••• → Connections → add your integration. A token alone is never enough; Notion requires an explicit per-page grant.Embed URL versus HTML block
| Embed URL | HTML block | |
|---|---|---|
| Where the file lives | Our host | Notion's file storage |
| Needs internet to render | Yes | No |
| Configuration source | Query string | Baked-in JSON script tag |
| Receives our updates | Yes, automatically | No. Frozen at your version |
| Install effort | /embed + paste | /html + upload, or 3 API calls |
| Works on Free plan | Yes | Yes |
| Safe to resell in a template | Risky: we could disappear | Yes. It is self-contained |
| Inspectable by you | View source | The file you downloaded |
Our recommendation: use the embed URL while you are iterating, and download the HTML block for anything that is finished, shared with a client, or embedded in a template you sell. The two channels serve the same bytes precisely so that switching costs nothing.