Meridian handbook
Features

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:

KeyDefaultMeaning
keyrequiredLowercase letters, digits and hyphens. Unique on the site; templates name the endpoint by it.
idwritten by check --fixThe 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).
pathrequiredStarts 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.
accesspublicpublic, member (a signed-in member of the portal, DMO bundle; needs an organisation input) or staff.
permissionWith access: staff, the permission the staff user needs.
csrftrue for a postfalse only on a public endpoint, for a post from another origin.
rate_limitthe install defaultOne limit or a list, each <count>/<window>/<key>. See Rate limits.
spamthe install defaultThe abuse controls on a public post. See Spam protection.
inputThe inputs, in form order. See Inputs.
validationRules across inputs. See Validation.
stepsWhat a valid post does, in order. See Steps.
respondrequiredExactly one of fragment, location, json, stream, plus on_error. See Responses.
cacheA 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.

KeyMeaning
typeA field type, or file for an upload.
label, helpPer locale. The drawn form uses them; error messages name the input by its label.
requiredThe input must have a value.
required_valueOn a boolean: it must be ticked. This is how consent is declared.
autocompleteThe HTML autocomplete token the drawn form writes (name, email, tel).
frombody (the default), path or query.
visible_when, required_whenA condition on other inputs, such as "topic == 'bookings'".
anything elseThe 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]
KeyMeaning
ruleA condition that must hold, in the schema’s condition syntax.
messagePer locale, shown when the rule fails.
fieldsThe inputs the message is attached to. Each must be declared.
severityerror (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.

StepWhat it does
storeKeeps the submission for the inbox, for a declared retention.
mailSends a transactional email from a template.
subscribeAdds the address to the newsletter with double opt-in (Newsletters).
auditWrites an audit log row: { action: area.verb, resource: $input }.
analyticsRecords an analytics event: { event: enquiry_submitted, subject: $listing }.
signalSets named values for a streamed response: { set: { ... } }.
queryReads published documents of one type, filtered by indexed fields, at most 100, for a json response.
source, job, webhook, pluginRead 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 }
KeyMeaning
asThe name the stored submission is bound to.
retentionRequired. A period (30d, 13m, 2y), at most forms.max_retention (2y by default). Or { default: 90d, while_open: 180d } (see below).
subjectA relation input the submission is about, such as $listing.
share_with_organisationDMO 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

- mail: { template: contact-to-staff, to: settings.contact_inbox }
- mail: { template: contact-receipt, to: $email }
KeyMeaning
templateRequired. 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/.
toRequired. $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_toUsed when to resolves to no address.
reply_toResolved 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:

KeyAnswers with
fragmentA template rendered with the result. The usual answer to a form.
locationA redirect to a path.
jsonA 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.
streamA template patched with a job step’s progress (stream: true on the step).
on_errorWith 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:

NameMeaning
valuesWhat the post carried, by input name.
errorsAfter 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.
submissionThe stored submission.
endpointThe endpoint’s key and path.
formThe form the post came from.
subscriberAfter 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

TemplateDraws
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:

KeyMeaning
action, methodWhere and how to post, with the site’s path prefix and locale.
inputsThe inputs in order: name, type, label, help, required, autocomplete, multiline, max, format, options.
hiddenHidden fields the abuse controls need, name to value.
honeypot, honeypotNameWhether the endpoint has a honeypot, and its field name.
challengeThe challenge token, for a data-mrd-challenge attribute on the form.
challengeScriptThe 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’s action adds the locale’s prefix, so on a site with cy the Welsh form posts to /cy/contact while the English one posts to /contact. Write the action from form.action in 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 /contact and a page whose address is /contact live side by side: GET shows the page, POST runs the endpoint. The Welsh page can have its own slug (/cy/cysylltu); its form still posts to /cy/contact.
  • Give every label, help and validation message a value in every locale. engine-dmo check does 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, and check does 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:

ControlWhat it does
honeypotA hidden field a person leaves empty. A post that fills it is refused.
min_fill_timeA 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_workA 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:

KeyCounts per
ipclient address
sessionsigned-in session
personsigned-in person
organisationthe organisation a post is about (needs a step with a subject)
endpointthe 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:

KeyDefaultMeaning
forms.max_retention2yThe longest retention a store step may declare.
forms.orphan_retention30dHow long submissions outlive their endpoint leaving the configuration.
forms.purge_schedule0 2 * * *When the purge runs: five-field cron, UTC.
endpoints.defaults.rate_limit.public_post10/hour/ip
endpoints.defaults.rate_limit.public_get60/minute/ip
endpoints.defaults.rate_limit.public_mail500/day/endpointAdded to a public endpoint with a mail step.
endpoints.defaults.rate_limit.member60/hour/session
endpoints.defaults.rate_limit.staff600/minute/session
endpoints.defaults.spam.honeypottrue
endpoints.defaults.spam.min_fill_time2s
endpoints.defaults.spam.proof_of_worktrue
endpoints.max_input_bytes256KBThe 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.