Embedding in another site
By default no site may frame a Homespun app in an <iframe>. A manifest can name the exact
third-party origins allowed to, and the app auto-sizes the iframe to fit its own content once
framed. Use this for a form or a small tool that lives on someone’s marketing site or blog, backed
by a Homespun app doing the work.
Who can be embedded
Section titled “Who can be embedded”Only a public app whose manifest needs no visitor identity. Visitor identity means a
collection admits anonymous create (write: ["anyone"]) and scopes update or delete to a
row subject such as creator or own, which needs a cookie to recognize the same visitor across
visits. Every usercontent cookie is dropped inside a cross-site frame, so an app that needs one
would not actually work embedded, even if it rendered. A plain public form, write: ["anyone"]
with no row-scoped update/delete, is the shape that qualifies.
This is checked on every request, not once at deploy: flip the app to private, or grow a collection that needs visitor identity, and framing stops on the very next response. There is nothing to revoke.
Declare the origin
Section titled “Declare the origin”Add the embedder’s exact origin to x-homespun-manifest.embedAncestors:
"x-homespun-manifest": { "embedAncestors": ["https://your-partner-site.example"]}Exact origins only, no wildcard, ever, with one exception for local development
(http://localhost:PORT and http://127.0.0.1:PORT). See the manifest
reference’s embedAncestors entry for the full grammar. Declaring
the field on an app that is not public, or that needs visitor identity, does nothing: it is left
declared but inert rather than rejected, so check the app’s own collections before assuming an
embed will render.
The snippet
Section titled “The snippet”Once at least one origin is declared, the app’s owner shell shows a ready-to-copy snippet on the app’s Settings tab, filled in with the app’s real URL. Hand that to whoever is embedding the app rather than retyping it by hand.
The snippet is an <iframe> plus a small <script> that listens for a resize message and grows
the iframe to match the app’s rendered height, so a fixed-height iframe never scrolls internally or
leaves dead space below a short form:
<iframe id="homespun-embed-your-app" src="https://your-app.example-usercontent-domain.com/" style="width:100%; border:0;" height="600" title="Your app"></iframe><script>(function () { var frame = document.getElementById("homespun-embed-your-app"); var expectedOrigin = "https://your-app.example-usercontent-domain.com"; window.addEventListener("message", function (event) { if (event.origin !== expectedOrigin) return; if (!frame || event.source !== frame.contentWindow) return; var data = event.data; if (!data || data.__homespun !== 1 || data.v !== 2 || data.kind !== "resize") return; if (typeof data.height !== "number" || data.height <= 0) return; frame.style.height = data.height + "px"; });})();</script>The two checks in that listener are load-bearing, not decoration. event.origin must equal the
app’s own origin exactly, and event.source must be that specific iframe’s own contentWindow,
not any other frame or script on the embedder’s page. A listener that resizes on any message, from
any window, is a defect: any page on the internet can post a message shaped like a resize envelope,
and without both checks it would drive the iframe to an arbitrary height on command. Do not simplify
either check away when adapting the snippet.
There is nothing to configure on the app side beyond declaring the origin: every app already loads the SDK, and the SDK already posts its rendered height to its parent whenever it detects it is framed. There is no manifest key for auto-resize and nothing to opt into.
The hosted convenience script
Section titled “The hosted convenience script”For a simpler paste at the cost of one more request to homespun on every page load, use the hosted
embed.js instead of hand-maintaining the snippet:
<script src="https://<main-domain>/embed.js" data-app="https://your-app.example-usercontent-domain.com/" data-height="600" data-title="Your app"></script>It creates the iframe itself and applies the same two checks as the inline snippet. This is the convenience path; the inline snippet above is the documented default, and it keeps working even if homespun is briefly unreachable after the embedder’s page has loaded, since no homespun-hosted script is involved.
Abuse guidance for an anonymous-write embed
Section titled “Abuse guidance for an anonymous-write embed”A public, anonymous-write form is a spam target, embedded or not, and embedding it on someone
else’s site makes it easier to find. If the embedded app’s collection admits anonymous writes
(write: ["anyone"]), declare both of these on that collection:
"antiAbuse": "turnstile","anonWriteBudget": { "perIpPerDay": 20, "perAppPerDay": 500 }antiAbuse: "turnstile"requires a Cloudflare Turnstile token on every anonymous write. This only works when the relay operator has Turnstile configured; if a deploy is refused namingantiAbuse, drop the key or ask the operator to enable it. Turnstile fails open: if Cloudflare cannot be reached in time, the write proceeds rather than being blocked, so a Cloudflare outage never takes an embedded form down.anonWriteBudgetcaps how many rows anonymous visitors may add per day, per IP and across the whole app. This has no configuration prerequisite and is what keeps holding even during a Turnstile outage, so declare it regardless of whether Turnstile is available on the relay you are deploying to.
Neither of these gates reading an embedded app’s data, only anonymous writes: they exist because framing a form makes it reachable from wherever the embedder’s own page gets traffic, which is usually the point, and also usually more traffic than a spammer scanning for open forms would otherwise find on their own.