Skip to content

Embedding a Form

This is how you connect a form on a static site to the Form Gateway — with no backend of your own, no build step, and a single <script> line.

The snippet (client/form-gateway.js) is dependency-free vanilla JS (~1.6 KB gzipped, shipped minified) and handles the entire secure flow: fetch the challenge → solve the proof of work in a Web Worker → submit the form data → show success/error inline. The page itself needs no secret — the site key is a public identifier (see Concept §2).

To try it out: examples/astro-demo is a working static Astro site that demonstrates the setup and all field options with live forms.


In the gateway’s admin UI (menu Tenants → New tenant):

  • Recipient address — where the form mail goes (never comes from the form).
  • Allowed origins — the domains forms are allowed to come from, one per line, with scheme and no path, e.g. https://www.example.com. For local testing, also add something like http://127.0.0.1:8099.
  • optionally PoW bits, spam threshold, daily quota.

After saving, the list shows the site key (site_…). The form needs it. In addition, at least one SMTP route must exist (menu SMTP routes) — usually the shared default route through your mail server.


<form data-gateway-site="site_YOUR_SITE_KEY"
data-gateway-url="https://forms.example.com">
<input name="name" required>
<input name="_gw_reply" type="email" placeholder="Your email">
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
<script src="https://forms.example.com/form-gateway.js" defer></script>

That’s all. data-gateway-url is the base URL of your gateway installation; the snippet is loaded from there and talks to that same address. Every <form> on the page with data-gateway-site is wired up automatically — even several at once.

A complete example is available at client/example.html.


AttributeRequiredMeaning
data-gateway-siteyesThe tenant’s site key (site_…)
data-gateway-urlyesBase URL of the gateway installation (no path)
data-gateway-formnoForm identifier, appears as a subject suffix and in the admin UI (e.g. contact, application)
data-gateway-sendingnoText shown while sending (default: “Sending …”)
data-gateway-successnoSuccess message (default: “Thank you! Your message has been sent.”)
data-gateway-errornoError message (default: “Sending failed …“)

There is no schema in the gateway: every field with a name is transmitted and ends up in the mail body — a new field only needs HTML, nothing else.

Field names may only contain a-z, A-Z, 0-9, _ and - (max. 64 characters). An invalid name (umlaut, space) causes the entire submission to fail — it is not simply skipped — so umlauts and special characters belong in the <label>, not in the field name.

Multiple values: fields sharing the same name (checkbox groups, <select multiple>) are joined with , web, hosting. An unchecked checkbox is missing entirely (normal HTML behavior). <input type="file"> is ignored — the gateway does not send attachments.

In the mail body, fields are listed alphabetically by field name, not in form order. Anyone who needs a fixed order can prefix a number (01_name, 02_company).

All name fields except the reserved ones end up in the mail body. Reserved:

Field namePurpose
_gw_replyThe sender’s reply address → becomes the mail’s Reply-To (validated). Optional, but recommended.
_gw_formAlternative to data-gateway-form, for when the identifier should come from the form itself.
_gw_hpHoneypot — inserted invisibly by the snippet itself; do not add it by hand.

Limits of the input hardening: max. 30 fields, 5,000 characters per field, 20,000 characters total; line breaks in header fields and invalid reply-to addresses are rejected.


The snippet appends a <p class="gw-status"> below the form and sets a data-state attribute on it (sending | success | error). This lets you style the status freely:

.gw-status { margin-top: 1rem; font-weight: 600; }
.gw-status[data-state="success"] { color: #087443; }
.gw-status[data-state="error"] { color: #b3261e; }
.gw-status[data-state="sending"] { color: #555; }

The message texts themselves come from the data-gateway-* attributes (§3) — so the language stays in your hands.


Without JavaScript, the <form> remains a normal form. Anyone who wants a fallback without JS can set, for example, action="mailto:contact@example.com" on the <form> — if JS runs, the snippet intercepts the submit (preventDefault) and uses the gateway; without JS, the browser’s mailto fallback kicks in.

The challenge is pre-fetched on the form’s first focus (which also starts the minimum fill-time clock) — this makes the submit click feel faster.


By default, the snippet is loaded directly from the gateway (<script src="https://forms.example.com/form-gateway.js">) — this way, every site automatically gets the same, current version. Anyone who prefers to serve it themselves (their own CDN, a stricter CSP) can download the file from …/form-gateway.js (minified) or …/form-gateway.src.js (readable) once and place it alongside their own assets. Functionally identical — but then it’s up to you to pull in snippet updates.

Caching: the gateway serves the snippet with Cache-Control: public, max-age=300, must-revalidate and an ETag. So customer sites get a fix within five minutes at most; revalidation only costs a 304 with no body. This is deliberately short: the snippet is security-relevant (PoW, field collection) and has no version in the URL — a long max-age would be a trap.

CSP note: the snippet starts the proof of work in a Web Worker from a blob: URL. Under a strict Content Security Policy, script-src (or worker-src) must allow blob:, and connect-src must allow the gateway URL. Without a worker, the snippet automatically falls back to solving it on the main thread.


SymptomCause / Solution
Network error fetching the challenge, form immediately reports an errorOrigin not on the tenant’s allowlist. Enter it exactly, with scheme and no path (https://www.example.com). The browser’s Origin header must match.
”Sending failed” despite a correct originTenant disabled (kill switch), daily quota exhausted, or rate limit — the exact reason is in the admin UI/log (the public API is deliberately tight-lipped).
Sending noticeably takes a long timePoW bits too high. The browser hashes individually via WebCrypto; 12 bits ≈ 1 s, 18 bits ≈ 40 s. Lower the PoW bits on the tenant (recommended 8–14).
Message doesn’t arrive, admin shows “failed: no smtp route”There is no (default) SMTP route. Create one under SMTP routes in the admin UI and check it with the test-send button.
Mail ends up in quarantineSpam score above the threshold. Release or discard it in the tenant detail view; adjust the threshold if needed.

For deeper analysis, load …/form-gateway.src.js (unminified) and use the browser devtools (Network tab: challenge, submit).


  • It stores no secret — the site key is public; security lies in the server-side verification pipeline (architecture.md, “The submit path”).
  • It sets no cookies and tracks nothing.
  • It loads no third-party resources — everything runs against your own gateway installation.