Forms and submissions
How to build a contact or enquiry form with no code: declaring an endpoint in endpoints.yaml, its inputs, validation, spam protection and rate limits, the store and mail steps, retention, the drawn form, the response templates and the submissions inbox.
A form on a Meridian site is a declared endpoint: an entry in the site’s endpoints.yaml that names the inputs a post carries, the checks they pass, the steps that run (store the submission, send mail, subscribe to the newsletter, and more) and the template that answers. You write no server code. The engine validates the input in the visitor’s language, applies CSRF protection, rate limits and spam controls, keeps the stored copy for the retention you declare, and draws an accessible form for you if you want it.
A contact form
Declare the endpoint:
# config/sites/<site>/endpoints.yaml
endpoints:
- key: contact
path: /contact/send
methods: [post]
access: public
rate_limit: 5/hour/ip
spam: { proof_of_work: true, honeypot: true, min_fill_time: 3s }
input:
name: { type: text, max: 120, required: true, autocomplete: name, label: { en: Your name } }
email: { type: text, format: email, required: true, autocomplete: email, label: { en: Your email address } }
message: { type: text, multiline: true, max: 2000, required: true, label: { en: Your message } }
consent: { type: boolean, required_value: true, label: { en: I agree to you keeping this message for 90 days } }
steps:
- store: { as: contact, retention: 90d }
- mail: { template: contact-to-staff, to: settings.contact_inbox }
- mail: { template: contact-receipt, to: $email }
respond:
fragment: partials/contact-sent.html and the inbox address it mails, in site.yaml:
# config/sites/<site>/site.yaml
settings:
contact_inbox: hello@example.org Write the response and the two emails:
{# templates/_shared/partials/contact-sent.html #}
<div id="form-contact" role="status"><h2>Thank you</h2><p>We have your message.</p></div> {# templates/_shared/emails/contact-to-staff.html #}
{% extends "emails/_layout.html" %}
{% block subject %}A message from {{ name }}{% endblock %}
{% block preheader %}Sent through the contact form.{% endblock %}
{% block body %}
<h1>A message from {{ name }}</h1>
<p>{{ name }} ({{ email }}) wrote:</p>
<blockquote>{{ message }}</blockquote>
{% endblock %} Email templates are covered in Transactional mail. They have no t(): see Forms in more than one language.
Add the interface strings the drawn form prints, in config/strings/<locale>.yaml for every locale the site enables:
# config/strings/en.yaml
strings:
form.leave.blank: Leave this field blank
form.submit: Send form.leave.blank labels the hidden honeypot field, which screen readers can still meet; without it the label prints as the key itself. form.<key>.submit (here form.contact.submit), then form.submit, is the button’s text; without either the button reads as the endpoint’s key with a capital letter (“Contact”).
Put the form on a page. Either let editors choose it with a form field on the content type:
# config/schema/page.yaml
extends: page
fields:
- name: contactForm
type: form
endpoints: [contact]
label: { en: Form } {% if entry.contactForm %}
<section aria-labelledby="contact-heading">
<h2 id="contact-heading">Send us a message</h2>
{{ entry.contactForm.render(submit="Send my message") }}
</section>
{% endif %} or print it in a template by its key with {{ enquiryForm("contact") }}. Printed that way the form is drawn as a plain post, without the in-place post a form field’s render() adds. enquiryForm() has no render(): engine-dmo check accepts enquiryForm("contact").render(), but it prints nothing.
Run engine-dmo check --fix to write the endpoint’s id, then engine-dmo check. Every mistake in the declaration, a step or a template is reported at its line.
The form posts in place when JavaScript is available and as an ordinary post without it. A failed post draws the form again with what the visitor typed, an error summary and each field’s error; a successful post answers with partials/contact-sent.html.
engine-dmo dev endpoint add <key> writes the start of an enquiry endpoint like this one (--from recipe:newsletter for a subscription). It writes endpoints.yaml and exits 0, but it writes no templates: engine-dmo check then fails until you write the response fragment, the on_error form and each mail template it names. Its labels are in the source locale only.
What happens to a post
A post passes the endpoint’s gates in this order, and only a post that passes all of them runs its steps:
flowchart TD
post["POST /contact"] --> access{"CSRF token<br/>and access"}
access -->|refused| deny["refused<br/>(401 member, 404 staff)"]
access --> rate{"rate limits"}
rate -->|over| r429["429, form drawn again"]
rate --> spam{"honeypot, fill time,<br/>proof of work"}
spam -->|refused| fake["fragment with empty values<br/>(looks like success)"]
spam --> valid{"inputs and validation"}
valid -->|invalid| again["form drawn again<br/>with errors"]
valid --> steps["steps in order:<br/>store, mail, subscribe, ..."]
steps --> respond["respond: fragment,<br/>location, json or stream"]
steps --> kept[("stored submission")]
kept -->|"retention passes"| purge["hard deleted with<br/>its uploads"]The endpoint
Each entry of endpoints: in config/sites/<site>/endpoints.yaml:
| Key | Default | Meaning |
|---|---|---|
key | required | Lowercase letters, digits and hyphens. Unique on the site; templates name the endpoint by it. |
id | written by check --fix | The stable id a form field stores. Never delete or change it: an endpoint whose id is removed gets a new one from check --fix, and schema apply then fails with a duplicate key error for the endpoint’s key until the old id is put back from version control (schema plan shows the endpoint removed and added again). |
path | required | Starts with /. A segment is literal or one {parameter}, which needs an input from: path. Two endpoints may not claim the same method and path shape. |
methods | [post] | get, post or both. |
access | public | public, member (a signed-in member of the portal, DMO bundle; needs an organisation input) or staff. |
permission | With access: staff, the permission the staff user needs. | |
csrf | true for a post | false only on a public endpoint, for a post from another origin. |
rate_limit | the install default | One limit or a list, each <count>/<window>/<key>. See Rate limits. |
spam | the install default | The abuse controls on a public post. See Spam protection. |
input | The inputs, in form order. See Inputs. | |
validation | Rules across inputs. See Validation. | |
steps | What a valid post does, in order. See Steps. | |
respond | required | Exactly one of fragment, location, json, stream, plus on_error. See Responses. |
cache | A duration such as 60s for a public get endpoint whose steps have no effect. A post, a member or staff endpoint, or one with a mail, store, subscribe, audit, analytics, job or webhook step cannot be cached. |
Inputs
Each key under input: is an input name, and its value is a field declaration with the same grammar as a content type’s fields (Field types): text, select, boolean, number, date, relation and the rest. blocks, repeater and portable_text have no place in a visitor form. The order you write them is the order of the drawn form and of the error summary.
| Key | Meaning |
|---|---|
type | A field type, or file for an upload. |
label, help | Per locale. The drawn form uses them; error messages name the input by its label. |
required | The input must have a value. |
required_value | On a boolean: it must be ticked. This is how consent is declared. |
autocomplete | The HTML autocomplete token the drawn form writes (name, email, tel). |
from | body (the default), path or query. |
visible_when, required_when | A condition on other inputs, such as "topic == 'bookings'". |
| anything else | The field type’s own parameters: max, min, format: email, multiline, options, to for a relation, and so on. |
A relation input names a published document by id, and a step can then reach that document’s fields ($listing.organisation). A relation’s to must name types that exist.
File uploads
input:
cv: { type: file, accept: [pdf], max_bytes: 10MB, label: { en: Your CV } } A file input needs a post endpoint, is read from the body, accepts image (JPEG, PNG, WebP, AVIF), pdf and csv, and is at most max_bytes (default media.max_upload_bytes, at most 50MB). The file is sniffed from its bytes and stored as a private library asset that only the submissions inbox can open. A file input needs a store step, whose retention deletes the file with the submission.
Validation
Each input is validated by its field type. validation: adds rules across inputs:
validation:
- rule: "message != empty"
message: { en: Tell us what you would like to know. }
fields: [message] | Key | Meaning |
|---|---|
rule | A condition that must hold, in the schema’s condition syntax. |
message | Per locale, shown when the rule fails. |
fields | The inputs the message is attached to. Each must be declared. |
severity | error (the default). |
An invalid post runs no steps.
Steps
Steps run in order after the input is valid and the abuse checks pass. Each step is one keyed entry.
| Step | What it does |
|---|---|
store | Keeps the submission for the inbox, for a declared retention. |
mail | Sends a transactional email from a template. |
subscribe | Adds the address to the newsletter with double opt-in (Newsletters). |
audit | Writes an audit log row: { action: area.verb, resource: $input }. |
analytics | Records an analytics event: { event: enquiry_submitted, subject: $listing }. |
signal | Sets named values for a streamed response: { set: { ... } }. |
query | Reads published documents of one type, filtered by indexed fields, at most 100, for a json response. |
source, job, webhook, plugin | Read a configured upstream, queue a job, send an outbound webhook, or call a project plugin. See Integrating and Extending. |
A value in a step is a literal, $input (an input’s value), $input.field (a field of the document a relation input names) or settings.key (a value from the site’s settings: in site.yaml).
store
- store: { as: contact, retention: 90d } | Key | Meaning |
|---|---|
as | The name the stored submission is bound to. |
retention | Required. A period (30d, 13m, 2y), at most forms.max_retention (2y by default). Or { default: 90d, while_open: 180d } (see below). |
subject | A relation input the submission is about, such as $listing. |
share_with_organisation | DMO bundle: true lets the partners of the subject’s organisation read the submission in the member portal. Needs subject. |
while_open keeps a shared submission longer once the organisation’s partners reply to it or write a note on it; it must be longer than default and needs share_with_organisation: true.
A store step with no retention is refused: a form that keeps data must say for how long.
- mail: { template: contact-to-staff, to: settings.contact_inbox }
- mail: { template: contact-receipt, to: $email } | Key | Meaning |
|---|---|
template | Required. The name of a template in an emails/ directory of one of the site’s template roots (templates/<site>/emails/ or templates/_shared/emails/), without the extension. check’s error for a missing one says “not under templates/emails/” and means any root’s emails/. |
to | Required. $input (an email input), settings.key, a literal address, or $relation.field for an address or a list of addresses on a related document. |
fallback_to | Used when to resolves to no address. |
reply_to | Resolved like to, and passed to the template as replyTo. The message’s Reply-To header is the site’s mail.reply_to, not this value, so a staff member who presses Reply answers the site’s address, not the visitor: print replyTo in the message for them to copy. |
A subject key is accepted and ignored: the subject always comes from the template’s subject block.
The template receives every input by name, submission (the stored submission, when a store step ran first), replyTo, and the common mail context. Every message a post sends, the staff notice included, is rendered in the locale the visitor posted in. A send that cannot be queued (a suppressed address, a template that fails) is recorded against the submission and does not fail the post. When neither to nor fallback_to resolves, the submission stays received.
Responses
respond: names exactly one shape:
| Key | Answers with |
|---|---|
fragment | A template rendered with the result. The usual answer to a form. |
location | A redirect to a path. |
json | A projection of a query, source or plugin step’s result: { from: results, fields: [title, url], shape: list } or shape: geojson with geometry: <geo field>. Nothing is exposed that is not listed. |
stream | A template patched with a job step’s progress (stream: true on the step). |
on_error | With fragment: the template a failed post renders. |
Without on_error, an endpoint drawn as a form answers a failed post with the form drawn again; any other endpoint renders its fragment with the errors.
The response template receives:
| Name | Meaning |
|---|---|
values | What the post carried, by input name. |
errors | After a failure: messages by input name, plus _summary (a list of path and message, in form order) and _count. Form-wide problems are under _form. |
submission | The stored submission. |
endpoint | The endpoint’s key and path. |
form | The form the post came from. |
subscriber | After a subscribe step: the subscriber. |
A refusal for spam reasons looks like success to the sender: the fragment renders with empty values.
Drawing the form
| Template | Draws |
|---|---|
entry.<formField>.render(submit=, enhance=) | The form a form field names. |
{{ enquiryForm("key") }} | Any endpoint on the site, printed as a plain post form. It has no render(). |
{{ subscribeForm("key") }} | The same, for a subscription endpoint. |
engine-dmo check does not check the key you pass to enquiryForm() or subscribeForm(): a misspelt key passes and prints nothing, so render the page once to see the form.
A printed form and render() write the CSRF and abuse-control fields, the error summary after a failed post (focus moves to it), and each input with its label, help, error message and aria-invalid and aria-describedby wiring, then the submit button. submit sets the button’s text (by default the string form.<key>.submit, then form.submit). enhance=false leaves out the in-place post, so the form is a plain post with or without JavaScript. Any other keyword becomes an attribute of the <form> element; id, action, method, enctype and data-on:submit are the engine’s. The drawn form’s id is form-<key>, its error summary form-<key>-errors and its button form-<key>-submit.
To write your own markup, use the description’s keys:
| Key | Meaning |
|---|---|
action, method | Where and how to post, with the site’s path prefix and locale. |
inputs | The inputs in order: name, type, label, help, required, autocomplete, multiline, max, format, options. |
hidden | Hidden fields the abuse controls need, name to value. |
honeypot, honeypotName | Whether the endpoint has a honeypot, and its field name. |
challenge | The challenge token, for a data-mrd-challenge attribute on the form. |
challengeScript | The script that fetches a challenge and computes the proof of work. Print it after the form. |
{% with form = enquiryForm("contact") %}
<form method="post" action="{{ form.action|url }}" id="form-contact"{% if form.challenge %} data-mrd-challenge="{{ form.challenge }}"{% endif %}>
{{ csrfInput() }}
{% for k, v in form.hidden %}<input type="hidden" name="{{ k }}" value="{{ v }}">{% endfor %}
{% if form.honeypot %}<p hidden><label for="hp">Leave this blank</label><input id="hp" name="{{ form.honeypotName }}" tabindex="-1" autocomplete="off"></p>{% endif %}
{% for fld in form.inputs %}
<p><label for="c-{{ fld.name }}">{{ fld.label }}</label>
<input id="c-{{ fld.name }}" name="{{ fld.name }}" value="{{ values[fld.name] }}"{% if errors[fld.name] %} aria-invalid="true"{% endif %}></p>
{% endfor %}
<button type="submit">Send</button>
</form>{{ form.challengeScript }}
{% endwith %} Your own markup must keep what the drawn form does for accessibility: a visible label for every control, an error summary, and the errors tied to their fields (Accessibility).
The drawn form draws text, email, URL and telephone inputs, textareas and checkboxes. It does not draw a select input’s options or radio buttons: a select input is drawn as a text box. For a choice, write your own markup from form.inputs (each input carries its options).
Forms in more than one language
- One path for every locale. An endpoint has one
path. The drawn form’sactionadds the locale’s prefix, so on a site withcythe Welsh form posts to/cy/contactwhile the English one posts to/contact. Write theactionfromform.actionin your own markup, never by hand. - An endpoint and a page may share a path. Endpoints answer only their declared methods, so a
post-only endpoint at/contactand a page whose address is/contactlive side by side:GETshows the page,POSTruns the endpoint. The Welsh page can have its own slug (/cy/cysylltu); its form still posts to/cy/contact. - Give every
label,helpand validationmessagea value in every locale.engine-dmo checkdoes not warn when an endpoint label lacks a locale (it does for schema labels), and a missing one falls back to the source locale’s text without notice. - Email templates have no
t(). The interface strings are not available in email templates:t()prints nothing there, andcheckdoes not flag it. Branch on the recipient’s locale instead:
{% block subject %}{% if locale == "cy" %}Mae eich neges gyda ni{% else %}We have your message{% endif %}{% endblock %} Spam protection
There is never a CAPTCHA. A public post is protected by three controls, each checked against the fields the drawn form carries:
| Control | What it does |
|---|---|
honeypot | A hidden field a person leaves empty. A post that fills it is refused. |
min_fill_time | A signed challenge dates when the form was drawn; a post sooner than this is refused with “That was quicker than a form can be filled in. Try again.” |
proof_of_work | A browser with JavaScript computes a small proof of work (about 256 hashes) over the challenge. A wrong proof is refused; a missing one is not, since a browser without JavaScript cannot compute one. |
Each challenge can be posted once: the same drawn form posted twice stores once. A page’s form fetches its challenge with challengeScript after the page loads; a browser without JavaScript posts without one, gets the form back with a challenge in it and what was typed kept, and its second post goes through. Refusals for the honeypot, a bad or replayed challenge or a bad proof answer as if they succeeded, so a bot learns nothing.
The install defaults in engine.yaml apply to every public post endpoint. An endpoint may add a control or lengthen min_fill_time, and may not remove a control the default sets. Spam controls need the forms module, which both bundles compile.
Rate limits
A limit is <count>/<window>/<key>: window is second, minute, hour or day, and key is what is counted:
| Key | Counts per |
|---|---|
ip | client address |
session | signed-in session |
person | signed-in person |
organisation | the organisation a post is about (needs a step with a subject) |
endpoint | the endpoint as a whole |
Each endpoint starts with the install default for its class (public post 10/hour/ip, public get 60/minute/ip, member 60/hour/session, staff 600/minute/session); a limit it declares replaces the default with the same key, and other keys are added. A public endpoint with a mail step also carries the install’s mail cap (500/day/endpoint). A post over a limit answers 429 with the form drawn again, what was typed kept, and “Too many attempts. Try again in …”.
The submissions inbox
The admin’s Submissions screen lists what visitors sent through the site’s endpoints that have a store step, newest first, filtered by endpoint and status (received, failed, forwarded, delivered, opened, replied, spam). A submission’s page shows its values labelled from the declaration, its uploads (downloaded as attachments, never displayed inline), what became of each mail it sent, and when it will be deleted.
Reading needs submissions.view on the site, which the built-in Administrator role holds; grant it to others in config/access/roles.yaml. Every read and download is audited.
Retention
A scheduled task, at forms.purge_schedule, hard deletes every submission past its declared retention, with its uploads, and every submission of an endpoint that has left the configuration once forms.orphan_retention has passed.
When check reports a problem
engine-dmo check reports every endpoint problem together. An endpoint’s diagnostic carries the line of the endpoint’s - key: entry (or of the step) and column 0, not the column of the offending key, so read the message for which key it means.
Configuration reference
In config/engine.yaml:
| Key | Default | Meaning |
|---|---|---|
forms.max_retention | 2y | The longest retention a store step may declare. |
forms.orphan_retention | 30d | How long submissions outlive their endpoint leaving the configuration. |
forms.purge_schedule | 0 2 * * * | When the purge runs: five-field cron, UTC. |
endpoints.defaults.rate_limit.public_post | 10/hour/ip | |
endpoints.defaults.rate_limit.public_get | 60/minute/ip | |
endpoints.defaults.rate_limit.public_mail | 500/day/endpoint | Added to a public endpoint with a mail step. |
endpoints.defaults.rate_limit.member | 60/hour/session | |
endpoints.defaults.rate_limit.staff | 600/minute/session | |
endpoints.defaults.spam.honeypot | true | |
endpoints.defaults.spam.min_fill_time | 2s | |
endpoints.defaults.spam.proof_of_work | true | |
endpoints.max_input_bytes | 256KB | The largest post body other than file uploads. |
Headless sites
On a headless site, a post endpoint is submitted through the content API’s forms operation, which answers JSON and hands out the challenge; a respond on such an endpoint is unused and engine-dmo check warns. See the content API.
Navigation
How menus work: navigation slots declared in site.yaml, navigation sets edited in the admin or seeded from files, the nav global and the nav tag in templates, and breadcrumbs, child pages and language switchers.
Transactional mail
How to configure the mail driver (Postmark, Amazon SES, Resend, SMTP or the development log), per-site senders, the email templates the engine and your forms send, bounce and complaint webhooks, the suppression list and the mail commands.