Skip to main content
A static site has no server to receive a contact form. Forms gives each app one: you create a form with a name, paste a snippet into your HTML, and every post lands in an inbox on the app page.

Create a form

Open the app, choose Forms, then New form. Or from a terminal:
The form gets a public endpoint, POST /api/v1/forms/<app>/<name>, and a copyable HTML snippet:
Form names use lowercase letters, digits, dashes and underscores. The name is part of the address, so it cannot be changed; create a new form and delete the old one instead.

Design the fields

The Forms tab appears only for static-site apps. In Design, add, remove and reorder fields. Each field has a type (text, email, long text, dropdown, checkbox, number, web address or hidden), a label, a name, a required flag, a placeholder and optional minimum and maximum length. A live preview sits beside the editor and the snippet under Embed follows your edits in three variants: HTML, HTML with a fetch script, and minimal markup without labels. A form that never sets fields keeps the default name, email and message fields. Once fields are set, the server validates each post (required, type, length, dropdown choice). Invalid posts answer 422 with {"error":..., "fields":{"email":"invalid_email"}}. Submitted names that are not declared are dropped, not stored; the spam trap field is always checked first. Optional extras: a success message (shown on the thank-you page and returned as message to JSON clients) and allowed sites. When allowed sites are set, browser posts from other origins get 403 and CORS only echoes the allowed origin; requests with no Origin header (curl, servers) still pass.

The endpoint address

The snippet uses the app’s first https domain. With no domain it uses the configured dashboard (public) URL. With neither, the page shows a warning and a Set the public URL button, because that endpoint only works from the machine running Levelrail.

Test it

Send a test submission stores a sample post through the real handler, marked Test in the inbox, without notifying channels. CLI: levelrail-cli forms test <app> <name>.

What the endpoint accepts

A browser post is redirected (303) to the form’s redirect URL, or shown a plain thank-you page when none is set. A request that sends JSON or Accept: application/json gets {"ok":true} instead, so a fetch call from your own script does not navigate away. Unless the form lists allowed sites, the endpoint answers cross-origin requests (Access-Control-Allow-Origin: *) because it is write-only: it takes no credentials and never returns stored data. Unknown form names answer 404, and one form’s address can never read another form’s submissions.

Spam protection

  • Spam trap. The hidden bot-field input is left empty by people and often filled by bots. A post that fills it is stored as spam, shown to the sender exactly like a success, and never sends a notification. Change the field name in the form settings.
  • Link flood. A post with more links than FORMS_SPAM_MAX_LINKS (default 5) across its fields is stored as spam.
  • Rate limits. Each client address may post FORMS_RATE_PER_IP_PER_MINUTE times a minute (default 10), and each form accepts FORMS_RATE_PER_FORM_PER_MINUTE (default 120). Over the limit gets 429 with Retry-After. The client address is read the same way as the rest of the API, so X-Forwarded-For is trusted only from a configured proxy.
  • Review. Spam is kept, not dropped. Mark a submission as spam or not spam in the inbox, or with forms spam and forms unspam.
Heavy abuse of a site’s own pages is better handled at the ingress with the firewall and bot shield; the form endpoint lives on the control plane address, so limits there protect only the form.

Inbox, notifications and export

The inbox lists the newest submissions first, loads more as you scroll, and searches field text on the server. Filter by spam or not spam, flag or delete single submissions, and export the current filter as CSV. Cells that a spreadsheet would run as a formula are prefixed with a quote. Pick any notification channel in the form settings to be told about each new, non-spam submission. The message lists up to eight fields, flattened and shortened, and links to the inbox when a dashboard URL is set. A failing channel never fails the post: the submission is already saved, the failure is logged, and the visitor still sees success.

Personal data, retention and deletion

Submissions usually contain personal data, so the defaults are conservative.
  • The sender’s address is never stored. Each submission keeps a salted hash (different salt per form, so hashes cannot be matched across forms), enough to spot repeat senders in the inbox but not to recover the address.
  • Submissions older than FORMS_RETENTION_DAYS (default 90) are deleted automatically. Set it to 0 to keep them until you delete them.
  • A form keeps at most FORMS_MAX_STORED_PER_FORM submissions (default 5000). When the cap is reached, spam is dropped first, then the oldest.
  • Deleting a submission, a form, or the app removes the stored data. Nothing is kept elsewhere.
  • Reading submissions needs the read:sensitive ability; creating, editing and deleting forms needs deploy.
  • Tell your visitors what you collect. The control plane stores what the form sends and nothing about the browser.

Limits and settings

Limits are read when the first post arrives after a start, so restart the control plane after changing them.

CLI

What it does not do yet

  • It does not find forms in your HTML automatically. You create each form and use its endpoint.
  • It does not verify CAPTCHA tokens or call third-party spam services.
  • It does not store uploaded files.
  • It does not send a confirmation back to the visitor.
Last modified on October 21, 2026