Forms

Forms collect submissions from your public website — contact forms, applications, quote requests — with validation on the server and an automation hook on every submission.

Defining a form

Create one under Forms with a name and fields. Each field has a name, a label, a type, and optionally required and (for choice types) options:

TypeBehaviour on submit
text (default)Stored as text
emailMust be a valid address; lowercased
numberMust be numeric
dateMust parse as a date
select, radioMust be one of the declared options
checkboxBooleans — or, with options, a list of allowed values

A form can be switched inactive at any time; an inactive form refuses submissions without being deleted.

The submission endpoint

This is what your site posts to. It needs a project API key holding **forms.submit** (Submit forms (public website)), sent in the ESC_API_KEY header:

POST /api/projects/{project}/forms/{form}/submissions
Content-Type: application/json
ESC_API_KEY: esc_…

{ "email": "visitor@example.com", "message": "Hi there" }

Without a key the answer is 401 (API_KEY_REQUIRED); with a key that lacks forms.submit, 403. (Authorization: Bearer esc_… is still accepted but deprecated — see API keys.)

Call it from your site's server, not the browser. Post the visitor's form to your own backend — a route handler, server action or serverless function — and forward it from there, so the key never reaches a web page. Make a dedicated key for this holding only forms.submit.

Posting straight from the browser works only for origins on the project's or the key's allowed origins; with neither set the endpoint sends no CORS headers and a browser on another site cannot read the answer, and an origin a set list leaves out is refused with 403 ORIGIN_NOT_ALLOWED. If you do post from the browser, give that forms.submit-only key its own allowed origins naming your site.

// On your site's server, e.g. a Next.js route handler
export async function POST(request) {
  const res = await fetch(`${API}/forms/${FORM_ID}/submissions`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      ESC_API_KEY: process.env.ESC_API_KEY,
    },
    body: JSON.stringify(await request.json()),
  });
  return new Response(await res.text(), { status: res.status });
}

What protects it:

  • **An API key with forms.submit**, checked before the form is even looked up, so a caller without one cannot tell which form ids exist.
  • Only declared fields are stored — arbitrary keys are dropped, nested objects rejected, so nothing unexpected enters your database.
  • Every value is validated against its declared type, with a 422 and per-field messages on failure.
  • Rate-limited per IP per form (30 a minute), applied before the key is even checked.
  • A honeypot. Render a hidden input named _hp (or _gotcha) that people never see. If it arrives filled in, the sender gets the usual 201 but nothing is stored and no automation runs — a bot learns nothing. Those two names are never stored either way. When proxying, forward the field as-is.
  • Still workspace-scoped: the form must belong to a project of the workspace the request's domain resolves to.
<input type="text" name="_hp" tabindex="-1" autocomplete="off"
       style="position:absolute;left:-9999px" aria-hidden="true">

Each stored submission records the data plus the submitter's IP and user-agent, and increments the form's submission counter.

When you proxy, the caller is your server: the stored IP and user-agent are its own (add fields to the form if you want the visitor's), and the per-IP limit applies to your server as a whole — 30 submissions a minute per form. Throttle individual visitors on your side as well.

Reading submissions

The Submissions view (and GET on the same route, authenticated with a session or an API key holding forms.read) lists submissions newest-first with pagination.

Doing something with them

The point of a form is rarely storage — attach an event with the form submission trigger. The submitted fields become the flow's trigger payload, so {{email}} and {{message}} are available to templates: auto-reply to the visitor, notify your team, file a record, call a webhook, or all four. A failing automation never rejects the visitor's submission — it lands in the event's execution log instead.