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:
| Type | Behaviour on submit |
|---|---|
| text (default) | Stored as text |
email | Must be a valid address; lowercased |
number | Must be numeric |
date | Must parse as a date |
select, radio | Must be one of the declared options |
checkbox | Booleans — 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
422and 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 usual201but 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.