# Endpoint reference

The Forms ingest endpoint contract — accepted content types, field handling, responses, redirects, errors, CORS, and rate limits.

Canonical: https://easerix.com/docs/forms/reference

<Answer>
Send an HTTP POST to `https://f.easerix.com/[form-id]` with form-encoded,
multipart, or JSON data — no authentication. JSON clients get back
`{"ok": true, "id": "..."}`; plain HTML posts get a 302 redirect when the form
is configured for one. Requests are rate-limited to 60 per minute per form
and IP.
</Answer>

## The endpoint

Every form has one public URL, shown on its page in the app:

```
https://f.easerix.com/<form-id>
```

| Method | Behavior |
|---|---|
| `POST` | Submit the form — the contract below |
| `OPTIONS` | CORS preflight — `204` with the form's CORS headers |
| `GET` | A friendly JSON hint that the endpoint accepts POST |

No API key, no auth header — the form id in the URL is the only credential. A paused or deleted form responds `404` to POST, indistinguishable from a form that never existed.

## Accepted content types

| Content-Type | Notes |
|---|---|
| `application/x-www-form-urlencoded` | What a plain HTML `<form method="POST">` sends — works with zero JavaScript |
| `multipart/form-data` | Accepted; **file contents are not stored** — filenames are joined into a `_files` field |
| `application/json` | A flat JSON object; body capped at 1 MiB |

## Field handling

- Every value is stored as a string. JSON numbers, booleans, and `null` are stringified (`null` becomes an empty string); nested objects and arrays are stored as compact JSON text.
- If the same field name is sent more than once (form-encoded/multipart), the last value wins.
- URL query parameters are **not** stored as fields — only the request body is.
- Name your email field `email`: it becomes the notification's Reply-To and the autoresponder recipient.

### Reserved field names

| Field | Meaning |
|---|---|
| `_gotcha`, `_honey` | Honeypot traps — a non-empty value flags the submission as spam; always stripped before storage |
| `cf-turnstile-response` | Cloudflare Turnstile token (the widget adds it automatically); may also be sent as an `X-Turnstile-Token` header; stripped before storage |
| `_files` | Written by the server when a multipart submission includes files — don't send it yourself |

There is **no** per-request redirect field (no `_next` or similar) — the post-submit redirect is configured per form in the app (see below).

## Success responses

The endpoint decides between a JSON response and a redirect based on how you called it. A request counts as a **JSON client** if any of these is true:

- the query string has `?ajax=1`,
- the `Accept` header contains `application/json`,
- the request body is `application/json`.

| Caller | Response |
|---|---|
| JSON client | `200` with `{"ok": true, "id": "<submission-id>"}` |
| HTML post, form set to **Redirect** with a URL | `302` — `Location` is the form's configured redirect URL |
| HTML post otherwise | `200` with `{"ok": true}` |

Spam-flagged submissions return the same success response as clean ones — deliberately, so bots can't detect the filters.

## Error responses

All errors are JSON with a single `error` field.

| Status | Body | When |
|---|---|---|
| `400` | `{"error": "could not parse submission"}` | Body didn't parse as the declared content type |
| `403` | `{"error": "origin not allowed"}` | Browser `Origin` not on the form's allowed-origins list |
| `404` | `{"error": "form not found"}` | Unknown form id — or a paused form (indistinguishable) |
| `429` | `{"error": "rate limit exceeded"}` | More than 60 requests/minute for this form from your IP |
| `500` | `{"error": "could not save submission"}` | Server-side storage failure |

## CORS

By default any origin may POST to a form; browser requests get a matching `Access-Control-Allow-Origin`. To lock a form to your own sites, list them under **Settings → Allowed origins** in the app (exact origin match, e.g. `https://mysite.com`) — other origins then get `403`. Requests without an `Origin` header (curl, server-to-server) are always allowed. `OPTIONS` preflight is handled, allowing `POST` and the `Content-Type` header.

## Rate limits

60 requests per minute per **form + client IP**. Exceeding it returns `429`; wait and retry.

## Submit with JavaScript

```js
const res = await fetch("https://f.easerix.com/YOUR_FORM_ID", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Accept": "application/json",
  },
  body: JSON.stringify({
    email: "visitor@example.com",
    message: "Hello from my site",
  }),
});

const data = await res.json(); // { ok: true, id: "..." }
if (!res.ok) {
  console.error(data.error);
}
```

The same snippet, pre-filled with your form's endpoint, is on the form's page in the app under **Integrate → fetch()**.

## Frequently asked questions

### Do I need an API key to submit?

No. The ingest endpoint is public by design — the form id is the only credential. (The management API at `/v1/...` is a different, authenticated surface.)

### Can I make a plain HTML form show JSON instead of redirecting?

Yes — append `?ajax=1` to the endpoint URL in your form's `action`, or send an `Accept: application/json` header. The query parameter is not stored as a submission field.

### How do redirects after submit work?

Set **Settings → Success behavior** to **Redirect** and provide the URL in the app. Plain HTML posts then get a `302` to it. JSON clients never get redirected — they always receive the JSON body.
