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.
1. Prerequisite: Create a Tenant
Section titled “1. Prerequisite: Create a Tenant”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 likehttp://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.
2. Minimal Setup
Section titled “2. Minimal Setup”<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.
3. Attributes on the <form>
Section titled “3. Attributes on the <form>”| Attribute | Required | Meaning |
|---|---|---|
data-gateway-site | yes | The tenant’s site key (site_…) |
data-gateway-url | yes | Base URL of the gateway installation (no path) |
data-gateway-form | no | Form identifier, appears as a subject suffix and in the admin UI (e.g. contact, application) |
data-gateway-sending | no | Text shown while sending (default: “Sending …”) |
data-gateway-success | no | Success message (default: “Thank you! Your message has been sent.”) |
data-gateway-error | no | Error message (default: “Sending failed …“) |
4. Field Names
Section titled “4. Field Names”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 name | Purpose |
|---|---|
_gw_reply | The sender’s reply address → becomes the mail’s Reply-To (validated). Optional, but recommended. |
_gw_form | Alternative to data-gateway-form, for when the identifier should come from the form itself. |
_gw_hp | Honeypot — 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.
5. Styling the Feedback
Section titled “5. Styling the Feedback”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.
6. Progressive Enhancement
Section titled “6. Progressive Enhancement”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.
7. Self-Hosting the Snippet (Optional)
Section titled “7. Self-Hosting the Snippet (Optional)”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.
8. Troubleshooting
Section titled “8. Troubleshooting”| Symptom | Cause / Solution |
|---|---|
| Network error fetching the challenge, form immediately reports an error | Origin 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 origin | Tenant 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 time | PoW 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 quarantine | Spam 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).
9. What the Snippet Does NOT Do
Section titled “9. What the Snippet Does NOT Do”- 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.