Ingest contract
This is the full reference for the submission endpoint, POST /f/:formId. Production base URL: https://forms.formward.eu. A machine-readable OpenAPI 3.1 fragment is published at /docs/openapi. For the higher-level walkthroughs, see Responses & redirects and File uploads.
Accepted content types
The endpoint accepts a submission body in any of three encodings:
application/x-www-form-urlencodedβ a classic HTML form POST.multipart/form-dataβ required when the form uploads files. File handling (count, per-file size, total size, and the MIME allowlist) is documented in File uploads.application/jsonβ the body must be a JSON object. Arrays, strings, numbers, andnullare rejected with400. An empty body is treated as no fields.
Whatever the encoding, fields are flattened into one payload object before any checks run, so behaviour is identical across encodings.
Classic vs AJAX is content-negotiated
The endpoint decides between a classic (redirect / HTML) and an AJAX (JSON) response from the request headers. A request is treated as AJAX when its Accept header includes application/json, or when it carries an X-Requested-With header. Otherwise it is treated as a classic form post. This applies to both success and error responses.
Success responses
| Mode | Status | Response |
|---|---|---|
| AJAX | 200 | { ok: true, id: "...", files: 0 } |
| Classic (redirect) | 302 | Redirect to the resolved target (the _redirect field, or the form's configured redirect URL) |
| Classic (default) | 200 | A minimal HTML thanks page when no redirect target is configured |
The AJAX id is the stored submission ID and files is the number of attachments stored. Redirect-target resolution and the default /thanks behaviour are covered in detail in Responses & redirects.
Error table
In AJAX mode each error returns the JSON body shown below. In classic mode the same status code is returned with a minimal HTML error page instead.
| Code | When | Body (AJAX) |
|---|---|---|
| 400 | Malformed body (a JSON body that is not an object), too many file parts, or a multipart upload sent to a server with uploads unavailable | { ok: false, error: "invalid request body" } { ok: false, error: "too many files" } { ok: false, error: "uploads not supported" } |
| 402 | Monthly submission quota reached (including any plan overage buffer) | { ok: false, error: "submission limit reached", upgrade: true } |
| 403 | Origin not in the form's allowed-origins list; Turnstile challenge failed; or file uploads are disabled / not in the plan | { ok: false, error: "origin not allowed" } { ok: false, error: "captcha failed" } { ok: false, error: "file uploads are disabled for this form" } { ok: false, error: "file uploads require the Professional plan", upgrade: true } |
| 404 | Form ID not found, or the form is inactive | { ok: false, error: "form not found" } |
| 413 | A file exceeds the per-file size limit, the request exceeds the total upload size limit, or the (non-multipart) request body is too large | { ok: false, error: "file too large" } { ok: false, error: "upload too large" } { ok: false, error: "submission too large" } |
| 415 | An uploaded file's MIME type is not in the allowed list (unsupported media type) | { ok: false, error: "file type not allowed: <mime>" } |
| 422 | Required GDPR consent was not given on a form that requires it (the _consent box was not ticked) | { ok: false, error: "consent_required" } |
| 429 | Per-IP or per-form rate limit exceeded. Includes a Retry-After header (seconds to wait) | { ok: false, error: "rate limit" } |
The over-quota 402 respects your plan's overage buffer: submissions are accepted (and metered as overage) up to the buffered ceiling before the endpoint starts returning 402. Spam and rate-limited requests are gated earlier and never consume quota.
CORS
The endpoint is CORS-aware so a cross-origin browser fetch() can read the JSON response:
- Preflight: a non-simple cross-origin request (for example one sending
Content-Type: application/jsonor anX-Requested-Withheader) triggers anOPTIONSpreflight. The endpoint answers204withAccess-Control-Allow-Methods: POST, OPTIONSandAccess-Control-Allow-Headers: content-type. Access-Control-Allow-Originreflects the requestOriginonly when that origin is permitted by the form's allowed-origins list (the same gate as the403 origin not allowedcheck), withVary: Origin. A disallowed origin gets a204preflight without the allow-origin header, so the browser blocks the real request.- A form with an empty allowed-origins list is treated as public: the origin is reflected (or
*when there is noOriginheader).
Honeypot
Include a hidden _gotcha field. Any submission where _gotcha is non-empty is silently stored as spam and returns a success-shaped response (a 200 { ok: true, files: 0 } in AJAX mode, or a redirect / thanks page in classic mode) so bots get no signal. It does not send a notification email or consume quota. The same indistinguishable-success behaviour applies to senders matched by a form's block list. See Special fields for the recommended hiding technique.
Control fields
Fields whose names start with an underscore are control fields: they are read for their effect and stripped before the payload is stored or emailed.
| Field | Effect |
|---|---|
| _redirect | Classic-mode post-submit redirect target. Must be a relative path or an absolute URL whose origin is allowed; otherwise ignored. Ignored in AJAX mode. |
| _subject | Sets the notification email subject line. |
| _replyto / _reply_to | Sets the Reply-To header on the notification email. |
| _consent | GDPR consent checkbox. Truthy values are on/true/1/yes. On a form that requires consent, a missing/false value yields 422 consent_required. When given, the form's server-side consent text and a timestamp are stored as proof; the field value itself is never persisted. |
| _gotcha | Honeypot (see above). The Turnstile token field cf-turnstile-response is likewise read and stripped, not stored. |
Minimal AJAX example
const res = await fetch("https://forms.formward.eu/f/<formId>", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
email: "visitor@example.com",
message: "Hello",
_consent: "true", // only if the form requires consent
}),
});
const data = await res.json(); // { ok: true, id, files } on success