API reference
One POST, a small JSON contract, and no surprises. The endpoint returns an opaque submission id on success and a plain, safe error otherwise: never a spam score, a recipient or a provider detail.
Endpoint
| Method | Path | Who calls it |
|---|---|---|
| POST | /f/{public_form_key} | Browsers and any client. The key is a public address, safe in HTML. |
| POST | /v1/forms/{form_id}/submissions | Your server, with a secret API key. |
The public endpoint is the everyday one and the default for a new form. Any origin may submit; the honeypot, content scoring and rate limits do the defending. Turn on an allowed-domains list and a request from anywhere else, or with no Origin at all, is refused with origin_not_allowed before it is parsed.
mtform.co and api.maketheform.com are the same service. Endpoints deployed on the longer host keep working; snippets use the short one.
Server-side submissions
Protected mode authenticates with a secret key instead of a public form key, carries a higher rate limit, and is the only way to reach a form set to protected. Create a key under Settings in the dashboard; it is shown once and stored only as a hash.
curl -X POST https://mtform.co/v1/forms/YOUR_FORM_ID/submissions \
-H "Authorization: Bearer mtf_sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{"email":"someone@example.com","message":"Hello"}'The origin allow-list and the bot challenge do not apply to an authenticated caller: both are questions about browsers. A key reaches only forms in its own workspace, and a form id belonging to anyone else answers form_not_found, exactly as an invented one does.
Request and response
Send application/x-www-form-urlencoded, multipart/form-data, application/json. A new submission is a 201. An Idempotency-Key that replays an earlier one is a 200. A browser sending Accept: text/html is redirected with a 303 instead; everything else gets JSON.
curl -X POST https://mtform.co/f/your-form-key \
-H "Content-Type: application/json" \
-H "Idempotency-Key: contact-form-load-5f3c" \
-d '{"name":"Ada","email":"ada@example.com","message":"Quote please"}'HTTP/1.1 201 Created
{
"ok": true,
"submission_id": "5f0c2b8e-3d1a-4c6e-9b7f-2a8d4e6c1b90",
"message": "Submission received"
}HTTP/1.1 422 Unprocessable Entity
{
"ok": false,
"error": {
"code": "validation_failed",
"message": "Please check the highlighted fields.",
"fields": { "email": "Enter a valid email address." }
},
"request_id": "0b5d7e2c-8f41-4a3e-b9d6-1c2e3f4a5b6c"
}Errors
One shape for all of them: { ok: false, error: { code, message, fields? }, request_id }. Branch on the code, show the message, log the request id.
| Code | HTTP | Public message | When |
|---|---|---|---|
| malformed_request | 400 | We could not read that request. | The body could not be parsed as the declared content type. |
| unsupported_content_type | 415 | That content type is not supported. | Content-Type is not one of the three supported types. |
| payload_too_large | 413 | That submission is too large. | The body or a field exceeded the size limits below. |
| validation_failed | 422 | Please check the highlighted fields. | One or more fields failed validation; see error.fields. |
| unauthorized | 401 | This endpoint requires a valid API key. | The form is in protected mode; use /v1 with a Bearer key. Also returned for a missing, malformed or revoked key. |
| origin_not_allowed | 403 | This form does not accept submissions from that address. | The request Origin is not in the form’s allowed list. |
| form_disabled | 403 | This form is not currently accepting submissions. | The form is paused and not accepting submissions. |
| form_not_found | 404 | This form does not exist. | No form matches that key. Also returned for a rotated key, revealing nothing. |
| challenge_failed | 403 | We could not verify that submission. Please try again. | The bot challenge (Turnstile) did not verify server-side. |
| rate_limited | 429 | Too many submissions. Please wait a moment and try again. | Too many requests from this IP and form. Respect the Retry-After header. |
| quota_exceeded | 429 | This form has reached its submission limit. | The workspace reached its monthly ceiling: the plan limit on Free, twice the limit on paid plans with overage. |
| idempotency_conflict | 409 | That idempotency key was already used with a different submission. | The Idempotency-Key was reused with a different payload. |
| service_unavailable | 503 | We could not accept that submission. Please try again. | A transient failure accepting the submission. Safe to retry. |
Limits and idempotency
Rate limits
| Limit | Value |
|---|---|
| Burst (per IP + form) | 5 / 30s |
| Sustained (per IP + form) | 30 / 10 min |
| Account ceiling (per form) | 600 / 10 min |
A 429 carries a Retry-After header. Windows are short and recover on their own, so one burst from a shared office IP does not lock out a building.
Payload limits
| Limit | Value |
|---|---|
| Max body (excl. uploads) | 256 KB |
| Max fields | 100 |
| Max field name | 128 bytes |
| Max field value | 20 KB |
| Max total text | 200 KB |
| Max JSON depth | 3 |
Idempotency
Send an Idempotency-Key header to make retries safe. Replaying the same key returns the original result with a 200. Reusing it with a different payload returns 409, so a bug that reuses keys surfaces instead of overwriting data. Keys are honoured for 24 hours.
How a submission is processed
Ordered cheap to expensive, so abuse is rejected before it costs anything. Once the transaction commits, the submission exists and nothing after the commit can turn it back into an error.
- Resolve the form by key
- Check the form is enabled and the workspace is active
- Rate limit, before quota, so an attacker cannot burn your allowance
- Origin policy, when the mode requires it
- Content type and size limits
- Parse, and split out the reserved control fields
- Honeypot
- Turnstile, verified server-side
- Field validation
- Content scoring
- Quota check
- Commit the submission and its outbox row in one transaction