Domma CMS User Manual

Forms API

Updated by Darryl Waterhouse on 29 September 2026 6 min read

Forms are JSON definitions stored in content/forms/. Every submission is stored as an entry in a collection - the form's collection action target when that is switched on, otherwise a collection with the form's own slug. The admin endpoints below use the collections permission family (there is no separate forms permission); the submit endpoint is public.

Form definitions

GET /api/forms

Requires: collections.read

List form definitions the caller's project scope allows, each with submissionCount, submissionsThisWeek and lastSubmissionAt.

POST /api/forms

Requires: collections.create

Create a form. title is required; slug is derived from it when omitted. Returns 201, or 409 when the slug is taken. A collection with the same slug is created alongside it (admin-only API access) unless one already exists.

FieldTypeDescription
titlestringRequired
slugstringOptional; slugified
descriptionstringOptional
fieldsarrayField definitions (name, label, type, required, logic, triggers, file ...)
settingsobjectMerged over the defaults below
actionsobjectemail, webhook and collection blocks
{
  "title": "Contact",
  "fields": [
    { "name": "name",    "label": "Name",    "type": "text",     "required": true },
    { "name": "email",   "label": "Email",   "type": "email",    "required": true },
    { "name": "message", "label": "Message", "type": "textarea" }
  ],
  "settings": {
    "submitText": "Submit",
    "successMessage": "Thank you for your submission.",
    "successRedirect": "/thanks?id={{entryId}}",
    "layout": "grid",
    "columns": 2,
    "submitAlign": "",
    "submitButton": { "style": "success", "size": "lg", "iconStart": "", "iconEnd": "send" },
    "secondaryButton": { "kind": "link", "label": "Cancel", "url": "/", "style": "ghost",
                         "iconStart": "", "iconEnd": "", "first": false, "stack": false },
    "honeypot": true,
    "rateLimitPerMinute": 3,
    "actionSlug": ""
  },
  "actions": {
    "email":      { "enabled": true, "recipients": "office@example.com", "subjectPrefix": "[Contact]" },
    "webhook":    { "enabled": false, "url": "", "method": "POST" },
    "collection": { "enabled": true, "slug": "contact" }
  }
}

submitAlign places the submit button: "" (the default - straight after the last field), left, center, right or full (stretched across the form). Any of the four gives the button a row of its own. A multi-step form's Previous / Next / Finish footer is not affected. The older submitSpan: "full" is still read, as left.

submitButton (optional) styles the button: style is one of primary (the default), secondary, success, danger, warning, info, outline, outline-success, outline-danger, outline-warning, outline-info or ghost; size is "", sm or lg; iconStart / iconEnd are icon names. On a multi-step form they apply to Next and Finish (icons to Finish only).

secondaryButton (optional) adds a button beside Submit: kind reset (Clear form) or link (Cancel, to url), with its own label, style and icons; first: true puts it before Submit and stack: true stacks the two. It shares the submit's size and submitAlign places the pair. Not shown on multi-step forms. A link url must be a path, #anchor, ?query or an http(s):, mailto: or tel: address - create and update answer 400 otherwise. Leave either key out for the default button.

The create and update responses carry a warnings array when the form and its target collection disagree - the collection is missing, requires a field the form does not collect, or lacks a field the form sends. Saving still succeeds.

GET /api/forms/:slug

Requires: collections.read

The full definition, including actions. 403 when the form belongs to a project outside the caller's scope.

GET /api/forms/:slug/public

No authentication required.

The definition without its actions block (recipients and webhook URLs stay private). This is what the public [form] embed renders from.

PUT /api/forms/:slug

Requires: collections.update

Shallow-merges the body over the stored definition (send whole fields, settings and actions objects). slug and createdAt cannot be changed.

DELETE /api/forms/:slug

Requires: collections.delete

Deletes the definition. Its submissions collection is left in place. Returns { "ok": true }.

Submitting a form

POST /api/forms/submit/:slug

No authentication required. A valid access token, when sent, is recorded as the submitter.

Accepts application/json (field names as keys) or multipart/form-data when the form has file fields. With a JWT the entry's meta.createdBy is the user's id and actions receive the user as {{user.*}}; without one the submission is anonymous.

curl -X POST https://example.com/api/forms/submit/contact \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ada","email":"ada@example.com","message":"Hello"}'
// Response 200
{ "ok": true, "entryId": "uuid", "ended": false,
  "message": "Thank you for your submission.", "redirect": "/thanks?id=uuid" }
// Error 400 - missing or invalid answers, or a trigger blocked the submit
{ "error": "Required fields missing: Email." }
// Error 404
{ "error": "Form not found." }
// Error 429
{ "error": "Too many submissions. Please try again later." }

What happens, in order:

  1. Spam checks (honeypot and timing - see Spam protection and limits).
  2. Triggers are resolved from the answers; a block-submit in force answers 400 with its message.
  3. Each field that is visible (field logic) and not hidden, disabled or masked by a trigger is checked for required and validation rules. Trigger-required fields count as required.
  4. The per-IP rate limit is applied.
  5. The visible answers are stored in the target collection with meta.source: "form:<slug>". A missing target collection is created from the form's fields rather than losing the submission. A collection validation failure answers 400.
  6. Email, webhook ({ "form": slug, "data": {...} } as JSON) and the settings.actionSlug Action run. Their failures are logged and raised as admin notifications; they never fail the submission.
  7. Trigger events run: run-action, notify and redirect (the first redirect wins over settings.successRedirect). {{entryId}} in the redirect is replaced with the new entry's id.

File uploads

Send multipart/form-data. A file part whose name matches a type: "file" field is checked against that field's file.maxSize (bytes, default 5 MB) and file.accept (comma-separated MIME types, wildcards such as image/* allowed), saved to the media library under a prefixed safe name, and stored on the entry as { "url", "name", "size", "mime" }. A file part for an unknown field is ignored; an empty file input is skipped. The server-wide upload limit (uploads.maxFileSize in config/server.json) still applies.

curl -X POST https://example.com/api/forms/submit/apply \
  -F name='Ada Lovelace' \
  -F email=ada@example.com \
  -F 'cv=@cv.pdf;type=application/pdf'

Spam protection and limits

CheckSettingBehaviour
Honeypotsettings.honeypot (default on)A non-empty _hp field is answered with an ordinary success and nothing is stored.
Timingsettings.honeypot_t is the time (ms since epoch) the form was rendered. A submit under 2 seconds later is answered with success and nothing is stored.
Rate limitsettings.rateLimitPerMinute (default 3)Per form and IP address, over a rolling minute. Counted only for submissions that pass validation. 429 when exceeded. Kept in memory, so a restart resets it.
Global limit-Every route shares the site-wide limit of 500 requests a minute per IP.

There is no CAPTCHA in core.

Triggers on the server

Field triggers (field.triggers[]) run in the browser, but anything they decide about submission is decided again on the server from the same engine (public/js/form-logic-engine.js), so disabling a button in the browser changes nothing:

  • block-submit - refused with 400 and the trigger's message.
  • hide-fields / disable-fields - those fields are excused from required checks and any value posted for them is dropped.
  • require-fields - those fields become required.
  • end-form - the answers so far are the submission; masked fields are excused (also in the collection's own required check) and the entry gets meta.outcome. With record: false nothing is stored and email, webhook and Actions do not run. The response has ended: true and the trigger's done message.
  • run-action, notify, redirect - performed after the entry is stored. notify exists only on the server (notification source core:form-triggers).

Messages that the visitor sees (banner, toast, block reason, end message and done message) fill {{field_name}} tokens with the posted answers - option labels for choices, nothing for passwords, files, images and any field a trigger hid, disabled or masked - so the 400 body and the done message match what the browser showed. Values are plain text, one line, at most 200 characters. Redirect URLs and notify titles are not filled.

Submissions

All submissions routes read and write the form's target collection, and only this form's entries in it (in a shared collection, the ones stamped meta.source: "form:<slug>"). A form in a project outside the caller's scope answers 404.

GET /api/forms/:slug/submissions

Requires: collections.read

An array of entries, newest first. [] when the collection does not exist yet.

GET /api/forms/:slug/submissions/export

Requires: collections.read

CSV download (<slug>-submissions.csv): one column per field label plus Date.

GET /api/forms/:slug/submissions/export/json

Requires: collections.read

JSON download of the raw entries.

DELETE /api/forms/:slug/submissions

Requires: collections.delete

Delete every submission of this form. A shared collection keeps other forms' entries.

DELETE /api/forms/:slug/submissions/:id

Requires: collections.delete

Delete one submission. 404 when the entry is not this form's.

POST /api/forms/:slug/submissions/:id/spam

Requires: collections.update

Flag ({ "spam": true }, the default) or unflag ({ "spam": false }) a submission. Sets data.spam and answers { "entry": {...}, "spam": true }.

POST /api/forms/test-email

Requires: an admin-level role (level 0 or 1).

Send a sample submission email through the site's SMTP settings to to (defaults to the SMTP from address). Answers { "ok": true, "message": "Test email sent to ..." } or 500 with the transport error.