Custom code
Your own HTML blocks, one stylesheet and analytics or chat embeds from a fixed list: what is allowed, what is removed, and why it only reaches your store.
Licensed font and self-hosted video uploads are available under Online Store → Custom code → Fonts and video. Font licence attestation is an owner-only decision.
Written 2026-09-23. Three ways to put something on your storefront that Formahand does not ship: a block of HTML, your own stylesheet, and a script embed from a fixed list of providers. All three are edited in the builder or under Online Store → Custom code, all three go into the draft, and none of them reaches a shopper until you publish.
The rule that shapes everything below: *a merchant can add anything to their own storefront, and nothing to Formahand or to anybody else's store.*
And one rule for everything on this page: **a block, the stylesheet and an embed never carry JavaScript of your own.** An embed is a provider you chose from a list; a block's markup is rebuilt from an allowlist that has no <script> in it. An app does not get an exception either: it supplies a template and a list of fields, and the store draws both. The one place your own script can run is a section's script, and only after you, signed in to the dashboard, switched scripts on for your store.
What you can use
Everything below is also returned by get_custom_code under allowed, so an agent can read the rules before it writes anything.
In a block's HTML
Kept: h2, h3, h4, h5, h6, p, a, img, ul, ol, li, strong, em, br, hr, blockquote, figure, figcaption, details, summary, dl, dt, dd, b, i, u, s, small, mark, sub, sup, abbr, time, code, pre, section, article, nav, table, thead, tbody, tfoot, tr, th, td, caption, div, span, iframe.
**No h1.** Not allowed in a block: the page already has its one h1 (the store or page title), and a second one confuses screen readers and search engines. Start a block's headings at h2; an h1 you send keeps its text and loses the tag.
Removed together with their contents: script, style, template, noscript, title, head, svg, math, object, embed, form, input, button, select, textarea, link, meta, base, video, audio, source, canvas, dialog, frame, frameset, portal, xmp, plaintext, listing, applet, keygen, fencedframe. Any other element loses its tags and keeps its text. Comments, on* handlers and the style attribute are removed.
| Attribute | Where |
|---|---|
class, id, title, lang, dir, hidden, up to 4 data-* attributes, values up to 64 characters, aria-label (up to 200 characters), aria-labelledby, aria-describedby and aria-controls (ids, namespaced like id), aria-hidden and aria-expanded (true or false), aria-current (page, step, location, date, time, true, false), tabindex (0 or -1), role (banner, complementary, contentinfo, definition, dialog, figure, group, img, list, listitem, navigation, none, note, presentation, region, search, separator, status, term) | every element |
src, poster, controls, muted, loop, autoplay, playsinline, preload, width, height | video |
src, srcset, type, media, sizes | source |
href, target | a |
srcset, sizes, src, alt, width, height, fetchpriority, decoding | img |
src, width, height, allowfullscreen | iframe |
colspan, rowspan | td |
colspan, rowspan, scope | th |
datetime | time |
open | details |
- Links: href is a site path, #anchor, https:, mailto: or tel:. target="_blank" is the only target. A link to another site gets rel="noopener noreferrer nofollow"; a site path or #anchor gets none, or rel="noopener noreferrer" with target="_blank".
- Images: src is https only; an empty alt is added when missing, and loading="lazy" unless you wrote loading="eager". fetchpriority (high, low, auto) and decoding (sync, async, auto) are kept.
- Video: iframe src is an https player address only: www.youtube.com/embed/…, youtube.com/embed/…, www.youtube-nocookie.com/embed/…, player.vimeo.com/video/….
- Ids: id="offer" is published as id="fhc-offer", and href="#offer" in the same block follows it.
- Scripts: A block runs no JavaScript. For analytics or chat, switch on a provider with set_script_embeds.
In CSS (the theme stylesheet and a block's CSS)
| At-rule | Kept |
|---|---|
@media | any media query, including prefers-reduced-motion |
@supports | feature queries |
@starting-style | entry styles for transitions (top level) |
@keyframes | from, to and percentage frames; page-wide names, so pick distinctive ones |
@property | syntax, inherits and initial-value; lets a custom property animate |
@font-face | every src is local(), an https file on fonts.gstatic.com, or a path under your store's own /media/ |
Removed: @import, @charset, @namespace, @layer, @container, @page, @font-feature-values, @counter-style and any other at-rule, with everything inside them. Inside rules: position: fixed, and any position that is not a plain word such as var() (a fixed layer can cover the checkout button); url() that is neither https nor a path under your store's own /media/; expression(), behavior:, -moz-binding and javascript:; any "<" (the sheet is published inside a style element).
- Theme stylesheet: Every selector in the theme stylesheet is published under [data-fh-storefront], the storefront's own <body>. :root, html and body mean that element, so page-wide colours and fonts go there.
- Block CSS: Every selector in a block's CSS is prefixed with that block's own class, so it only reaches inside the block. :scope or & means the block itself.
- Keyframes, @property and @font-face are page-wide and not scoped.
- Honour reduced motion: put animations inside @media (prefers-reduced-motion: no-preference), or switch them off under prefers-reduced-motion: reduce.
What every theme already gives you
Colour tokens --fh-background, --fh-foreground, --fh-accent, --fh-on-accent, --fh-muted, --fh-border: The store's brand colours as chosen in the builder (set-colors). Every theme defines them, so CSS built on them follows the merchant's palette.
.promo{background:var(--fh-accent);color:var(--fh-on-accent)}
.promo small{color:var(--fh-muted);border-top:1px solid var(--fh-border)}
Font tokens --fh-font-heading, --fh-font-body: Defined when the merchant picks a font pairing (set-fonts); give a fallback so the theme's own font applies otherwise.
.promo h2{font-family:var(--fh-font-heading,inherit)}
.promo p{font-family:var(--fh-font-body,inherit)}
Keyframes each theme ships, usable by name in animation:
| Theme | Keyframes |
|---|---|
Bold Street (shopco) | pulse, spin |
Luma (atelier) | enter, exit, pulse |
Corner Pantry (market) | marquee |
Noir (studio) | fadeIn, marquee |
Gridline (orebi) | enter, exit |
Warm Roast (catalog) | pulse |
/* Luma and Gridline: fade and rise in; start values in --tw-enter-* */
.promo{--tw-enter-opacity:0;--tw-enter-translate-y:1rem;animation:enter .5s ease-out both}
/* Luma and Gridline: the reverse of enter, with --tw-exit-* */
.promo.is-leaving{--tw-exit-opacity:0;animation:exit .3s ease-in both}
/* Bold Street, Luma and Warm Roast: a soft opacity pulse */
.badge{animation:pulse 2s ease-in-out infinite}
/* Every theme: define your own and it works anywhere */
@keyframes promo-rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:no-preference){.promo{animation:promo-rise .5s ease-out both}}
Limits
| What | Limit |
|---|---|
| HTML in one block | 32 KB |
| CSS in one block | 8 KB |
| The theme stylesheet | 64 KB |
| Blocks per store | 40 |
| Script embeds | 4, one per provider |
| Everything together | 256 KB of blocks and CSS |
Motion without JavaScript
Animation is allowed. These are the techniques to reach for, each as it passes the allowlist:
Scrolling marquee or ticker. Repeat the row's content twice inside a flex track and slide the track by half its width.
@keyframes vt-marquee{to{transform:translateX(-50%)}}
.ticker{overflow:hidden}
.ticker-track{display:flex;width:max-content;gap:3rem}
@media (prefers-reduced-motion:no-preference){.ticker-track{animation:vt-marquee 30s linear infinite}.ticker:hover .ticker-track{animation-play-state:paused}}
Reveal as the shopper scrolls. A scroll-driven animation, inside @supports so other browsers show the content at rest.
@keyframes vt-rise{from{opacity:0;transform:translateY(40px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:no-preference){@supports (animation-timeline:view()){.reveal{animation:vt-rise linear both;animation-timeline:view();animation-range:entry 0% cover 35%}}}
Entrance on page load, one item after another. One keyframe, a different animation-delay per item.
@keyframes vt-in{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:no-preference){.hero-line{animation:vt-in .6s cubic-bezier(.2,.7,.2,1) both}.hero-line:nth-child(2){animation-delay:.12s}.hero-line:nth-child(3){animation-delay:.24s}}
Hover and press feedback. Transitions on transform, box-shadow and colour.
.btn{transition:transform .2s,box-shadow .2s}
.btn:hover{transform:translate(-2px,-2px);box-shadow:4px 4px 0 var(--fh-foreground)}
.card img{transition:transform .4s}
.card:hover img{transform:scale(1.05)}
Accordion or FAQ that opens and closes. details and summary need no script; style the open state.
details summary{cursor:pointer;list-style:none}
details summary::after{content:'+';float:right;transition:transform .2s}
details[open] summary::after{transform:rotate(45deg)}
Carousel the shopper swipes. A scroll-snap row; it scrolls by touch, trackpad or keyboard.
.rail{display:flex;gap:1rem;overflow-x:auto;scroll-snap-type:x mandatory}
.rail>*{flex:0 0 min(80%,320px);scroll-snap-align:start}
Floating or spinning decoration. An infinite keyframe on a span or image; position: absolute inside a position: relative parent.
@keyframes vt-float{50%{transform:translateY(-12px) rotate(4deg)}}
@media (prefers-reduced-motion:no-preference){.chip{animation:vt-float 6s ease-in-out infinite}}
Sticky bar or heading. position: sticky works everywhere. position: fixed works in a section that declares schema.fixed: true, not in a custom block.
.buy-bar{position:sticky;top:0;z-index:5}
Editor rules
- The theme's home sections (hero, collection, more-products, styles) appear at most once each. Hide one with set-section-visibility, take it off the page with remove-section, and put a removed one back with add-section { sectionId }. The home page has to show at least one visible section or block. Reorder with move-section (index among the page's sections; blocks keep their places) or set-layout.
- Every page is one ordered list of sections and custom blocks (the draft's document.layout): "home", "catalog", "product" and "page:<content page slug>", whose one section is its own body, "main". Place a block with position { placement: "before" | "after", target: a section id, a block id, "start" or "end", page?, slot? } on add-custom-block, move-custom-block or update-custom-block; reorder a whole page with set-layout { page, order }, which lists every section once and every block that should be there.
- The header.top slot (under the announcement bar, above the navigation) and the footer.top slot (above the footer) hold blocks too, on every page but the payment page: position { target: "start", slot: "header.top" }.
- sectionId without position still means right after that section and after the blocks already there, so blocks added one by one stay in the order they were sent. A block beside a hidden section keeps rendering.
- A block target that is not on the storefront, a section on the wrong page, or a set-layout that leaves out a block on that page is refused, naming the command.
- Take a theme section you are replacing off the page with remove-section (or hide it with set-section-visibility { sectionId, visible: false }), never with display:none in CSS: the builder does not apply the theme stylesheet, so a section hidden only by CSS shows up in the merchant's editor next to your design.
- One element of a placed section: update-section { instanceId, settings } for its text, set-element-style { instanceId, key, align, size, visible, spacing, order, offsetX, offsetY, width, height, layer, mobile, tablet } for its look, place and layer (any marked element; place per device), set-element-order { instanceId, group, order } for a group. Theme sections take areaId instead of instanceId.
- Hiding a hidden section, or showing a shown one, changes nothing and is not an error.
- At most 30 commands per apply_storefront_commands call. A batch applies whole or not at all, and a refusal names the command's position, type and target.
- At most 40 custom blocks per store.
- Text colours must stay readable on the background; set-colors refuses a pair that is too close.
- A logo or favicon is one of the store's own uploads; set_logo and set_favicon upload for you.
Recreating a reference site
When a merchant says "make it look like this site", an agent works in this order:
- Explore before building. Inspect desktop and phone layouts plus relevant states: delayed overlays, hover and keyboard menus, search, product options, quick add, cart, newsletter success/error and returning visits. Record URL, viewport, locale, elapsed time and visitor/consent conditions for each observation. Mark findings observed, inferred or unknown; one visit cannot establish hidden campaigns or A/B variants.
- Follow dependencies when browser access permits: inspect rendered structure, computed styles, fonts, media and relevant script/app requests to distinguish theme behavior from app and commerce-backend behavior. Never capture or retain credentials, personal buyer data or unrelated network payloads. If access is unavailable, say so rather than inventing evidence.
- Verify representative platform features through the actual save-and-render path before promising them. Documentation establishes a contract, not a complete shopper flow. Record unsupported or unverified behavior and a visible substitute; keep task-specific evidence only when useful, and do not create permanent reference requirements or claim a feature is verified from docs alone.
- Preserve presentation, not someone else's protected implementation: reproduce layout, rhythm, type scale, colors and interaction intent using assets the merchant owns or is licensed to use. Do not copy source code, logos, product names, photography, reviews, ratings or claims without authorization. Use real merchant products and truthful copy; make every substitution explicit.
- Start from nothing: set-theme { presetId: "blank-canvas" } takes the theme's home sections off the page and places starter sections that become the store's own (hero, product grid, text and image, questions, newsletter, header, footer). Change or replace them; they are ordinary sections.
- Pick the closest theme and font pairing (list_themes, set-theme, set-fonts), then set-colors, the merchant's logo and navigation. Use an available open font by exact family name, or ask the signed-in owner to upload a properly licensed WOFF2 face; do not assume arbitrary remote font hosting is allowed.
- Build every visual band as a section with save_section, never as a raw custom block: read describe_sections { parts: ["editable"] }, then mark each element that prints a text, image or link setting with data-fh-el and each reorderable flex or grid container with data-fh-group. The save refuses a section whose words the merchant could not select, and its answer lists what the builder can select. Put words and pictures in settings, never in the template.
- Take theme sections you replace off the page with remove-section (add-section brings one back); set-section-visibility only hides one for now. Never hide or move a theme section with CSS: the builder does not apply the theme stylesheet to theme sections, so it would show the old section beside the new design.
- Read get_custom_code
allowedand describe_sections before implementation. Section templates support self-hosted video and responsive picture sources; video autoplay must follow the documented muted/loop/poster and reduced-motion rules. CSS handles presentation; agent-authored interactions belong in isolatedbehavior, not legacy same-originscript. - Put each section's layout and motion in its own styles, built on the theme tokens (--fh-accent, --fh-foreground, --fh-font-heading), so it follows the merchant's colours; keep the theme stylesheet for page-wide things. A section that shows products reads a collection setting, so it follows the catalog.
- For each advanced interaction, check the current
behaviorbridge contract and the store's Scripts in sections setting. Behaviors run only after the owner/admin enables it; builder editing previews are inert. Provide a useful no-behavior fallback, and never promise scratch reveals, campaign targeting or external widgets without a real implementation/integration. - Compare local preview and reference at identical viewports and observed states. Test keyboard and screen-reader semantics, reduced motion, errors, real product options, search, cart totals and newsletter outcomes as relevant. Track captured, implemented, locally verified, store-verified and published separately; list unknowns and gaps. Preview is not publication, and this playbook does not authorize publishing.
Sections instead of blocks
When a layout repeats, or the merchant should edit it later in the builder, build a section instead of a raw HTML block: typed settings the builder turns into fields, a template and its own styles, saved once and placed as often as needed. Nothing inside a custom block can be selected or edited in the builder. The rules are in sections and in describe_sections. Start from a theme (list_themes, set-theme), then adjust. The loop: describe_sections → save_section → preview_section → apply_storefront_commands → get_preview_link → publish_storefront.
Mark every element that prints a text, image or link setting with data-fh-el="<setting key>" and every reorderable flex or grid container with data-fh-group="<key>": the merchant selects, types into, aligns, resizes, hides, reorders and nudges each one. save_section refuses a new section that prints such a setting only outside marked elements. describe_sections { parts: ["editable"] } has the rules and an example.
1. Custom HTML blocks
A block is markup you write, placed anywhere in a page's layout: between or around the home sections, around the catalog, product or a content page's body, or in the header or footer of every page. In the builder you drag it where you want it (or use Add custom block in the outline); an agent uses set_custom_block with position (agents, "Page layout").
A block is one piece of markup: in the builder the merchant can move, hide or remove it, but cannot select or edit anything inside it. For anything with words, pictures or a layout someone should change later, build a section whose elements are marked instead, and keep blocks for one-off markup such as an embed or a legal note.
What survives, and what does not:
| Kept | Notes |
|---|---|
h2–h6, p, ul, ol, li, dl, dt, dd, blockquote, figure, figcaption, details, summary, section, article, hr, br | h1 is not on the list: the page already has its one <h1> (the store or page title), so start a block at h2; an h1 keeps its text and loses the tag, and set_custom_block says so in removed |
strong, em, b, i, u, s, small, mark, sub, sup, abbr, time, code, pre | inline text |
table, thead, tbody, tfoot, tr, th, td, caption | colspan, rowspan, scope |
div, span | the layout you need |
a[href] | a site path, #anchor, https:, mailto: or tel:, and target="_blank" is the only target. A link to another site gains rel="noopener noreferrer nofollow"; a link inside your store (a site path or #anchor) gains nothing, or rel="noopener noreferrer" when it opens a new tab |
img[src] | HTTPS only; gains an alt when it has none, and loading="lazy" unless you wrote loading="eager"; fetchpriority (high, low, auto) and decoding (sync, async, auto) are kept |
iframe[src] | HTTPS www.youtube.com/embed/…, youtube.com/embed/…, www.youtube-nocookie.com/embed/… or player.vimeo.com/video/… only, and nothing else |
class, id, title, lang, dir, hidden, up to four data-* | id is namespaced (below); a data-* value is at most 64 characters; hidden is written bare, whatever value it came with |
aria-label, aria-labelledby, aria-describedby, aria-controls, aria-hidden, aria-expanded, aria-current, role, tabindex | on any element, each value checked: a label of at most 200 characters; ids for the three that name other elements (namespaced like id); true or false; aria-current one of page, step, location, date, time, true, false; tabindex 0 or -1; role one of banner, complementary, contentinfo, definition, figure, group, img, list, listitem, navigation, none, note, presentation, region, search, separator, status, term. They never count against the four data-* |
Everything else goes. script, style, form and its controls, svg, math, object, embed, base, meta, link, video, audio, template, noscript, title, textarea, xmp and friends are dropped with their contents; any other unknown element loses its tags but keeps its text; comments, doctypes, on* handlers and the style attribute are removed; the remaining text is escaped. javascript: and data: addresses are refused wherever a URL is allowed.
**id is namespaced.** You write id="offer" and the page gets id="fhc-offer". A link you wrote as href="#offer" follows it, so an in-page anchor still works — but a block can never claim an id the theme, the checkout mount or another block already uses.
Limits. 32 KB of HTML and 8 KB of CSS per block, 40 blocks per store, and one 256 KB budget across every block and the theme stylesheet together (§5).
A block is storefront markup and nothing else. It cannot be referenced from an email template — those have their own language, and a block's allowlist is written for a browser, not for a mail client.
Why an allowlist and not a parser
Formahand rebuilds your markup from the allowlist rather than editing the string you typed. Nothing you typed is ever emitted as markup: every element and attribute that survives is written out again by Formahand, and everything else becomes escaped text or disappears. That is why sanitizing is idempotent — running it twice gives the same string — which is what lets the save-time and the render-time pass agree on one answer.
A spec-complete HTML parser would be the other way to do it, and the ones worth having need a DOM: something the browser has and the server that renders your page does not. So the allowlist stands, and these are the constructs it is written against, each one refused: <scr<script>ipt>, an entity-encoded javascript:, unquoted attribute injection, backtick attribute values, the svg and math namespaces, raw-text elements (noscript, textarea, title, xmp), unclosed tags, and a < that starts no tag at all.
2. Your own stylesheet
One CSS field per store, in the builder under Styles and on the Custom code screen; set_theme_css for an agent. 64 KB.
Refused on the way in: @import, @charset, @namespace, expression(, behavior:, -moz-binding, javascript:, position: fixed (a fixed layer can cover the checkout button; a popup uses the overlay a section's script can open, which always has a close button; a section with schema.fixed may use it, under the limits in its §6), any url() that is neither https: nor a path under your own /media/, and **any < at all** — the sheet is rendered inside a <style> element, and a < there is either a mistake or an attempt to end it early.
A position has to be one of the plain words static, relative, absolute or sticky (or inherit, initial, unset, revert, revert-layer), with or without !important. Any other value is removed with its declaration: var(), env(), attr(), calc() or any other function, and a word written with a backslash escape, because each of them can still come out as fixed in the browser. So `--p: fixed; position: var(--p) keeps --p and loses the position`. A property name written with a backslash escape (posit\ion) is removed too, whatever it spells, and the same rules reach into nested rules (.card { &:hover { … } }) and into @media, @supports, @starting-style and @keyframes. Anchor positioning (anchor-name, position-anchor, anchor()) is kept: a box cannot be anchored to a layer fixed to the window, only follow a sticky one, which stays inside its own parent. These rules hold for your stylesheet, for every block's CSS and for a section's styles; a section with schema.fixed may still write position: fixed itself, as a plain word (sections §6). The Custom code screen names each kind of removal when you save.
At-rules kept: @media, @supports, @starting-style (all three keep their rules, scoped like any other), @keyframes (from, to and percentage frames), @property (its syntax, inherits and initial-value; a type such as '<length>' is kept for you, written with the CSS escape for <) and @font-face, when every src is local(), an https: file on fonts.gstatic.com, or a path under your own /media/. A face with any other source is removed whole: your storefront only loads fonts from itself and from that host, so it could never have loaded. @layer, @container, @page and every other at-rule are removed with their contents. Keyframe, @property and font names are page-wide, so choose names the theme does not already use (the table above lists the theme's own keyframes). set_theme_css and set_custom_block answer with removed, naming each at-rule that did not survive and why.
The size limit is the field's own. The theme stylesheet keeps its full 64 KB; until 2026-09-26 a sheet longer than a block's 8 KB was cut short without a word.
The at-rule's own condition is checked too. An @media (min-width:600px) prelude goes back into the <style> element verbatim, so it is held to the same allowlist as a selector rather than merely matched against @media — @media a</style><script>…</script>{…} is dropped whole, not re-emitted. The condition is also read trimmed, so a media query works wherever it sits in the sheet and not only as its first character.
Every selector is scoped. Before the sheet is rendered, each selector is rewritten under the storefront root, [data-fh-storefront], which is an attribute on the storefront's own <body> and on nothing else. :root, html and body all mean that root, which is where a page-wide background or font belongs; everything else becomes a descendant of it. So a rule can reach your storefront and nowhere else — not the dashboard, not another store, not a page Formahand renders.
The sheet is rendered on the storefront only. It is not applied to the builder canvas or the dashboard, on purpose: see §6. Since 2026-09-27 each custom block's sealed preview frame carries it, scoped as the storefront scopes it, so a block styled from the sheet looks right in the builder; and Preview draft in the builder opens the saved draft on the storefront itself.
3. Script embeds
An embed is never JavaScript you wrote, and there is no "paste your own script tag" field (your own code belongs in a section's script). What you can do is switch on a provider from the table below and give it the one identifier that provider issued you. Formahand renders that provider's exact tag, as the provider documents it, with the page's own nonce.
| Provider | You supply | Looks like |
|---|---|---|
| Google Analytics 4 | Measurement ID | G-XXXXXXXXXX |
| Plausible | Site domain | example.com |
| Meta pixel | Pixel ID | 1234567890123456 |
| TikTok pixel | Pixel ID | CXXXXXXXXXXXXXXXXXXX |
| Crisp | Website ID | a UUID |
| Tawk.to | Property and widget path | 5f1b…4d/default |
| Intercom | App ID | ab1cd2ef |
Four at most, one per provider. Each identifier is checked against a strict pattern; nothing else about it is stored, and nothing you type is ever interpolated anywhere but into that provider's own documented tag.
What a shopper's browser then does. Each provider carries a one-line note — shown beside its field and returned by get_custom_code — saying whether it sets cookies and what it reports. An analytics or advertising tag generally sets cookies and tells the provider about the visit; a chat widget stores an identifier so a conversation survives a reload. Whether you have to tell shoppers first, and whether you need their consent, depends on where you and they are. That is your decision and your responsibility: Formahand shows you what the tag does and does not advise you on the law.
4. The policy
Every storefront HTML response carries a Content-Security-Policy, and every <script> on the page carries that response's own nonce.
default-src 'self';
script-src 'nonce-<random>' <the payment host, on the payment page only>
<the hosts of the embeds this store switched on>;
style-src 'self' 'unsafe-inline' <the webfont host> <the theme bundle>;
style-src-attr 'unsafe-inline';
font-src 'self' <the webfont files host> <the theme bundle>;
img-src 'self' data: https:;
connect-src 'self' <the payment hosts> <the embed hosts>;
frame-src <the payment hosts, on the payment page> <the two video players>
<the embed frame hosts>;
media-src 'self' https:;
object-src 'none'; base-uri 'none'; frame-ancestors 'none';
form-action 'self' https://formahand.com
Notes on the parts that are not obvious:
- **
script-srcis a nonce, not a host list.** The theme's own scripts (the carousel, the sort control, the variant picker), the JSON-LD block and the payment page's mount script are all ours, and all carry the nonce. A section's script is your store's own: it is served from your store's address, carries the nonce too, and is left off the cart, the payment page, account pages and the builder. A host appears only when a store actually uses it: the payment host only on the payment page, an embed host only for an embed that store switched on. A script that arrives any other way does not run, which is the second line of defence behind the sanitizer. - **
style-srckeeps'unsafe-inline', deliberately.** The themes are ports of React storefronts that style thousands of elements withstyleprops. A nonce instyle-srcwould switch'unsafe-inline'off — that is how CSP works — and take the whole storefront's layout with it. Custom CSS is scoped and sanitized; style injection is not the threat this policy is holding back. - **
img-srcstays open to HTTPS.** A block may show any HTTPS image, and a one-pixel image is not distinguishable from a photograph, so narrowing it would promise something it could not keep. - A cached page still gets a fresh nonce. Storefront pages that are the same for every shopper may be served from a cache, policy and all. Serving a stored nonce twice would defeat it, so the nonce is replaced with a new one — in the body and in the header — before the response leaves.
- The builder's edit view of a page (the merchant editing in the dashboard) differs in one directive only:
frame-ancestorsis the dashboard's own origin instead of'none', so the dashboard can show the page in a frame. Everything else in the policy is the same, and that view is never cached and never indexed. - Pages Formahand writes itself — the closed-store page, the unsubscribe confirmation, the error page — get a policy that runs no script at all.
Every theme's product, cart and checkout page is checked on each release: every <script> on the page carries that response's nonce, and the payment host appears in the policy of the payment page and of no other.
5. Limits and metering
| What | Ceiling |
|---|---|
| HTML in one block | 32 KB |
| CSS in one block | 8 KB |
| Blocks per store | 40 |
| The theme stylesheet | 64 KB |
| Script embeds | 4, one per provider |
| Everything above, together | 256 KB |
The budget is the number that matters. Forty blocks of 32 KB is a ceiling nobody reaches and nobody can act on; 256 KB of blocks and CSS together is more hand-written markup than a small shop has ever needed, and it keeps your draft small enough to load in one request. Every way of saving the draft meets the same budget — the dashboard and the API alike — and the refusal names the size and says what to do about it.
The meter is a snapshot, like the media meter: bytes used, blocks and embeds, re-read from the draft on every call to /api/storefront-editor and shown on the Custom code screen, on Settings → Plan & billing beside the other meters, and in describe_walls / get_plan as features.customCode (used, limit, blocks, embeds, and the reading's time; never a wall, because the budget is the same on every plan).
Every write goes through the same ownership check as any other builder write: owners and admins only for a session, and the storefront:write scope for a token — storefront:publish to publish, as always.
Why your code cannot reach anything else
What holds the three features above, and what it means for you.
6. Your storefront is a separate origin
Custom HTML, custom CSS and script embeds run on the storefront origin — <slug>.store-on-formahand.app or the merchant's own domain — and nowhere else. They are never rendered into the dashboard document.
Since 2026-09-23 that origin is on a different registrable domain from the dashboard (domains.md "Two domains"). Free storefronts used to be <slug>.formahand.com, a sibling of the dashboard; they are now <slug>.store-on-formahand.app, and formahand.com serves no storefront at all. That is the boundary this whole page rests on, and it is the reason the feature can exist: everything below is defence behind it, not instead of it.
The dashboard origin holds the session that can change prices, refund orders, read customers and mint API tokens. The storefront origin holds a cart. If a merchant's markup ran in the dashboard, a sanitizer bug would not be a defaced shop — it would be the merchant's own account, and then the next merchant's, because the dashboard is one origin for everybody.
So the builder does not render a block inline. It puts the block in a sealed sandbox="" iframe, written straight into the page as a srcdoc document: no scripts, no forms, no top-level navigation, and an opaque origin that belongs to nobody.
That frame has no same-origin access, and never will. With it the preview would run as the dashboard and the sandbox would be decoration. The price you see is that the frame cannot measure itself, so the block preview has a fixed height and its own scrollbar. Publish and look at the storefront for the real thing.
The theme stylesheet is not rendered in the builder canvas either, for the same reason — a merchant's CSS in the dashboard could move a button under the merchant's cursor. It is rendered inside the sealed block frames, which hold nothing but the block. So never hide a theme section with CSS: the canvas would still draw it. Hide it with set-section-visibility (or the outline's checkbox), which every home section accepts, the product grid included, as long as the home page still shows one section or block. A hidden section is one slim row in the canvas, not the section drawn dimmed.
7. Cookies on your storefront
Your storefront sets a small set of first-party cookies and nothing else: the shopper's cart, a discount code they typed, the delivery destination they estimated with, the password on a store still in launch mode, and — when shopper accounts are on — the shopper's own sign-in session. If you publish a cookie notice, that is the list to describe, plus whatever the embeds you switched on set (§3).
Every one of them is locked to your store's own hostname: set with no Domain, and accepted by the browser only for the host that set it. So a neighbouring hostname cannot write a cookie your store will read, and your store cannot write one that reaches the dashboard.
**The shopper's session cookie is HttpOnly, Secure and SameSite=Lax.** That matters the moment you switch on a script embed: the provider's script runs on your store's origin and can read anything on the page that is not HttpOnly, and the sign-in session is not one of those things. But you chose the provider, and what their script does on your store is between you and them — which the Custom code screen says beside the switch.
8. Your code stays in your store
Custom code is stored with your store's own data, in the theme draft and on the published theme version, and it is your store that renders it. Formahand never parses your markup to render anything of its own: it passes the editor call through and records the meter, and that is all. Two stores cannot see each other's code, because neither the draft nor the published version ever leaves the store it belongs to.
Publishing carries appearance.customCss and appearance.embeds onto the theme version with the rest of appearance, and the draft rebuild reads them back, so a merchant who reopens the builder sees what they published.
9. Defence in depth, listed
- What you write is sanitized when you save it, so what is stored is already safe.
- It is sanitized again on render, so a block that reached your store's data another way, or was saved by an older version of the editor, still cannot bring markup of its own.
- CSS is scoped at render time — block CSS to the block's own class, the theme sheet to the storefront root — so a stored sheet that was never scoped is still confined.
- The policy on the response refuses any script without the nonce, and any host the store did not switch on.
- The origin the whole thing runs on holds nothing but a cart, and its cookies are host-locked. The cart's own forms and its JSON endpoint accept a change only from the store's own pages: a post from another site, or from another store on the shared domain, is refused before it is read.
- Readiness gates the feature:
describe_readiness { feature: "custom_code" }, and every write tool refuses with that checklist until your store is up and serving.
10. Agent tools
| Tool | Scope | Does |
|---|---|---|
get_custom_code | storefront:read | the inventory and the meter: blocks with their anchors and sizes, the stylesheet, the embeds, the provider table with each one's consent note, and allowed: everything in "What you can use" above |
set_custom_block | storefront:write | adds, changes or removes one block in the draft; answers with what was removed (an h1, an at-rule) |
set_theme_css | storefront:write | replaces the stylesheet in the draft; answers with what the allowlist removed |
set_script_embeds | storefront:write | replaces the embed list in the draft |
All four write the draft. publish_storefront is still the only thing that reaches a shopper.