Skip to content

Forms + Submissions API

Design forms in the admin (contact, event signup, lead capture), collect submissions, export to CSV. Replaces the old mailto: waitlist stub with real stored submissions.

Admin: form definitions

http
GET    /v1/spaces/{slug}/forms
POST   /v1/spaces/{slug}/forms
GET    /v1/spaces/{slug}/forms/{uuid}
PUT    /v1/spaces/{slug}/forms/{uuid}
DELETE /v1/spaces/{slug}/forms/{uuid}      # archives (soft) — submissions kept

Auth: auth:sanctum + space.scope.

POST body:

json
{
  "slug": "contact",
  "label": "Kontaktformular",
  "fields": [
    { "key": "name",    "type": "text",     "label": "Name",      "required": true },
    { "key": "email",   "type": "email",    "label": "E-Mail",    "required": true },
    { "key": "message", "type": "textarea", "label": "Nachricht", "required": false },
    { "key": "consent", "type": "consent",  "label": "DSGVO-Einwilligung", "required": true }
  ],
  "submit_action": "store",
  "notification_email": "office@example.com",
  "redirect_url": "https://example.com/danke",
  "success_message": { "de": "Danke! Wir melden uns." },
  "rate_limit_per_ip": 5
}

Field typetext · textarea · email · tel · number · select · radio · checkbox · file · hidden · consent.

submit_action:

ValueEffect
store (default)Persist the submission only
emailPersist + notify notification_email
webhookPersist + HMAC-signed POST to webhook_url
leadPersist + create/update a Subscriber

On save, the field definitions are compiled into a JSON-Schema (compiled_schema) and validated server-side by the same ComponentSchemaValidator used for content blueprints — one validator for both surfaces.

Public: submit

http
POST /v1/cdn/{space}/forms/{slug}
Content-Type: application/json

Anonymous. Throttled 20/min/IP at the route, plus the per-form rate_limit_per_ip (default 5/day/IP via a daily-rotating IP hash).

json
{
  "_hp": "",
  "data": { "name": "Lisa", "email": "lisa@example.com", "message": "Hallo" }
}
  • _hp is the honeypot — a hidden field that must stay empty. If a bot fills it, the API returns a fake 200 {ok:true} and stores nothing (the bot doesn't learn it was caught).
  • data is validated against the form's compiled schema; missing required fields → 422 {error:"validation_failed", errors:{…}}.
  • Values are coerced to scalars + strip_tags + length-capped (5000) on capture.

Success: 201 {data:{ok:true, submission_uuid, success_message, redirect_url}}.

Spam protection

LayerMechanism
HoneypotBuilt-in _hp field, always on
Rate limitRoute throttle + per-form rate_limit_per_ip (daily IP hash, no raw IP stored)
Challenge (optional)spam_provider: turnstile | hcaptcha + spam_config

Admin: submissions

http
GET /v1/spaces/{slug}/forms/{uuid}/submissions?per_page=50&include_spam=0
GET /v1/spaces/{slug}/forms/{uuid}/submissions.csv
POST /v1/spaces/{slug}/forms/{uuid}/submissions/{submissionUuid}/spam   # toggle

The CSV export streams field-keyed columns (definition order) + meta columns (submitted_at, uuid, spam_score), excluding spam-marked rows. File fields are omitted from the CSV.

Privacy / DSGVO

  • No raw IP stored — only a daily-rotating SHA-256 hash for rate-limiting + dupe detection (can't be reversed into a stable id).
  • User-Agent stored as a 16-char hash, not the full string.
  • Referer truncated to scheme + host.
  • A consent-type field records explicit opt-in; pair with submit_action: lead to feed double-opt-in newsletter flows.

Example: drop-in HTML form

html
<form id="contact">
  <input name="name" required>
  <input name="email" type="email" required>
  <textarea name="message"></textarea>
  <input name="_hp" style="display:none" tabindex="-1" autocomplete="off">
  <button>Senden</button>
</form>
<script>
document.getElementById('contact').addEventListener('submit', async (e) => {
  e.preventDefault();
  const fd = new FormData(e.target);
  const data = {}; fd.forEach((v, k) => { if (k !== '_hp') data[k] = v; });
  const res = await fetch('https://peacock-cms.webhoch.com/v1/cdn/your-space/forms/contact', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ _hp: fd.get('_hp') || '', data }),
  });
  const json = await res.json();
  if (json.data?.ok) location.href = json.data.redirect_url || '/danke';
});
</script>

See also