Form API reference

Technical details for connecting a website form to MailContact.

Using a website builder? Start with Add a contact form for setup steps and a prompt to copy.

Copy your form link from your project’s settings. It looks like this, with your form’s own ID at the end:

your form link
POST https://mailcontact.app/api/forms/your-form-id

It accepts both FormData (what a native HTML form sends) and application/json. Delivered submissions land in your inbox and trigger your notification channels. Messages held in Spam wait for review without notifications or quota usage.

Fields

fieldrequirednotes
nameoptionalShown as the sender. Falls back to the email if empty. Maximum 200 characters.
emailrequiredThe sender's email. Validated: invalid or missing addresses are rejected with a 400.
subjectoptionalMaximum 300 characters.
messagerequiredThe message body. Must be non-empty and at most 5,000 characters.

A required text field named company_website must be present and empty. A non-empty value marks the submission as bot traffic and it is silently dropped (that's the honeypot spam trick). Every other unknown field is ignored.

The script injects this field automatically for marked forms. A missing field raises FORM_CHECK_MISSING; a filled field is silently discarded. An empty field never bypasses the other spam, rate or verification checks. The trap can still be recognized by a more capable bot.

The hosted client silently discards a filled trap before any configuration or submission request. It resolves with { ok: true } to preserve the normal confirmation, matching the endpoint’s bot response. Direct API requests retain the same server-side trap.

Recommended: use the MailContact script

Add data-mailcontact-form to your form and load our script once per page. Keep your existing styles. The script handles sending, accessible inline feedback, automatic verification and retries. It preserves the visitor’s text on an error and resets the form only after a confirmed success.

The automatic handler blocks further submissions while sending and for 10 seconds after a confirmed success. Submit controls stay disabled during this delay, and keyboard submissions are blocked too. Fields become editable again and the confirmation stays visible. Errors allow an immediate retry unless the server provides a Retry-After delay.

contact.html
<form action="https://mailcontact.app/api/forms/your-form-id" method="POST" data-mailcontact-form>
  <label>Email <input type="email" name="email" maxlength="320" required /></label>
  <label>Message <textarea name="message" maxlength="5000" required></textarea></label>
  <button type="submit">Send</button>
  <p data-contact-status role="status" aria-live="polite" aria-atomic="true"></p>
</form>
<script defer src="https://mailcontact.app/forms/v1.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

One script supports multiple forms and forms added later by your site. It injects the hidden check once when a form is initialized and verifies its presence and empty value at submission. A removed or disabled field blocks sending and preserves the draft. It submits to each form’s action, independently of where the script is hosted. Use HTTPS (or localhost) for automatic verification. The generator also includes a native noscript fallback; for a manual integration without JavaScript, use the plain HTML example below.

You control the HTML, CSS, labels and button contents. Only the supported input names must stay the same; other fields are ignored by the API. Set data-mailcontact-success="Thanks, we’ll reply soon." on the form to customize its confirmation text. For a fully custom flow, use MailContact.send() below.

Language of feedback

Sending, verification, confirmation and error messages follow the visitor’s browser language preferences. French, English, Spanish, German, Italian and Portuguese are supported, including regional variants such as fr-CA and pt-BR. The first supported browser preference is used; if none matches, feedback falls back to English.

Set data-mailcontact-language="fr" on an automatic form to force French for that form, independently of the browser and other forms on the page. Supported language overrides take priority; an empty or unsupported value falls back to browser detection. The setting is read on every submission. Labels, the original button contents and data-mailcontact-success stay under your control; custom confirmation text is never translated automatically. Native HTML validation bubbles remain controlled by the browser.

Force French feedback on your form
<form action="https://mailcontact.app/api/forms/your-form-id" method="POST"
  data-mailcontact-form data-mailcontact-language="fr">
  <!-- Keep your existing fields, button and status element here. -->
</form>

Forms owned by a Free account display a small “Processed by mailcontact.app” link. Pro and Plus forms have no MailContact attribution. The script reads this setting from the inbox owner’s plan, so it updates after a plan change without replacing the embed. The setting may be cached for up to a minute. Style the data-mailcontact-branding element to fit your site.

If your site sets a Content Security Policy, permit the script’s origin in script-src and the form endpoint’s origin in connect-src and form-action. No inline JavaScript permission is needed for this embed. The v1 URL receives compatible updates; a future breaking integration change will use a new version. You can listen for the bubbling mailcontact:success event to add your own success behavior.

Fully custom interfaces

Load the same script, then call MailContact.send() from your own handler. Omit data-mailcontact-form so the automatic handler leaves your interface alone. You control every step, loading state, error message and confirmation; the function handles the request, signed verification and resubmission. It never changes your DOM or clears your fields.

custom-form.js — run after the script has loaded
const endpoint = "https://mailcontact.app/api/forms/your-form-id";
const form = document.querySelector("#contact-form");
MailContact.prepare(form); // Inject once before the visitor fills the form.
const language = form.getAttribute("data-mailcontact-language") || undefined;
const text = MailContact.getMessages(language);
const config = await MailContact.getConfig({ endpoint, language });
// If config.branding is true, show a visible "Processed by mailcontact.app"
// link to https://mailcontact.app in your own form layout.

async function submit() {
  showStatus(text.sending);
  try {
    await MailContact.send({
      endpoint,
      data: new FormData(form),
      language, // Optional: omit for automatic browser language detection.
      onVerification: () => showStatus(text.verification),
      brandingElement: document.querySelector("[data-mailcontact-branding]"),
      // signal: abortController.signal, // optional cancellation
    });
    showYourConfirmation();
  } catch (error) {
    // error.code, error.status and error.retryAfter (seconds or null)
    // let you choose your own translated feedback. Keep the draft.
    showYourError(error);
  }
}

MailContact.prepare(form) only adds the hidden field; it does not attach a submission handler or change your design. Call it once on each mounted form before interaction. It never recreates a removed field or clears an existing value. data also accepts an object containing name, email, subject, message and the required company_website value. The client snapshots fields for verification retries. It confirms reception or silently discards a filled trap with { ok: true }, and throws MailContact.FormError for failures. Missing checks use FORM_CHECK_MISSING, network failures use NETWORK_ERROR, cancellation or timeout uses ABORTED. There is a 60-second timeout and at most one verification retry.

MailContact.getConfig({ endpoint }) makes a public GET request to the form URL and returns only { branding: true | false }. Custom interfaces render that attribution themselves when required and pass its element as brandingElement (or a function returning it). Every send checks the server’s setting. On Free, an absent, hidden, unreadable or unclickable link to https://mailcontact.app raises BRANDING_REQUIRED before any message is posted; Pro and Plus do not need a link. This is a browser-side integration check, not server proof of a third-party page’s contents. It does not expose the owner’s identity or billing details. React, Vue and other frameworks can call these functions after the script has loaded; the raw API remains available too.

The custom client localizes FormError.message automatically; code, status and retryAfter stay unchanged. Pass language: "fr" to send() or getConfig() to override the browser. A custom handler reads the form’s data attribute itself, as above. MailContact.getMessages(language?) returns the matching feedback text for your own UI, including sendingButton, sending, verification, success and wait(seconds). It returns a separate object so your custom text cannot change other forms. A failed automatic proof uses VERIFICATION_FAILED. No translation service or additional language request is used; the API’s raw JSON responses keep their existing codes and language.

Automatic verification for suspicious submissions

The hosted script handles verification for you. Custom clients can send _verification: "1" to support automatic verification. A suspicious request can return HTTP 403 with code: CHALLENGE_REQUIRED and a signed challenge object. It creates no delivered message or quota charge. Solve the challenge and resend the original fields with the JSON solution in _proof. The standalone /form-challenge.js helper exposes window.mailcontactSolveChallenge(challenge, signal?), which resolves to that JSON string. Suspicious custom requests with the required form check but without verification support are held in Spam for review; omitting verification support never bypasses the check.

The proof expires after five minutes, is bound to the exact fields, form and client, and is accepted only once. Retain the draft if verification fails. Native browser forms receive an automatic verification page instead. Strongly suspicious messages are received into the Spam folder, without notifications or quota usage; the public success response does not reveal the classification.

Plain HTML form

The zero-JavaScript version. Name your inputs after the fields above and the browser does the rest:

contact.html
<form action="https://mailcontact.app/api/forms/your-form-id" method="POST">
  <input type="text" name="name" placeholder="Your name" />
  <input type="email" name="email" placeholder="[email protected]" required />
  <input type="text" name="subject" placeholder="Subject" />
  <textarea name="message" placeholder="Your message" required></textarea>
  <button type="submit">Send</button>
  <input name="company_website" type="text" tabindex="-1" autocomplete="off"
    style="position:absolute;left:-10000px" aria-hidden="true" />
</form>

A native form post navigates to the endpoint's JSON response on success, or an HTML verification page when needed. Submit with our script (above) to keep visitors on the page and show an inline success state.

JSON with fetch

Send application/json with a Content-Type header. Same fields, same validation:

submit.js
const res = await fetch("https://mailcontact.app/api/forms/your-form-id", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    name: "Ada",
    company_website: "",
    email: "[email protected]",
    subject: "Hello",
    message: "Trying out the endpoint.",
  }),
});

const data = await res.json();
if (data.ok) {
  // show your inline "thanks, message sent" state
}

You can also POST a FormData object from JavaScript: skip the Content-Type header and pass new FormData(formElement) as the body.

CORS

CORS is open: the endpoint answers with Access-Control-Allow-Origin: * and handles the OPTIONS preflight, so browser submissions work from any origin: your domain, localhost, a staging URL, or a static host like GitHub Pages. Nothing to allowlist.

Responses and errors

On success the endpoint returns 200 with {"ok": true}. This also covers messages held in Spam and silently discarded filled checks; it does not promise inbox delivery. Validation errors are JSON, shaped {"ok": false, "code": "...", "error": "..."}. Verification uses {"ok": false, "code": "CHALLENGE_REQUIRED", "challenge": {...}}, or an HTML page for native browser posts. Malformed JSON, incorrect field types, duplicate form fields and overlong fields return 400:

statuserrorwhen
403Automatic verification required.CHALLENGE_REQUIRED: solve the signed challenge and resend the original fields with _proof. An invalid, expired or reused proof receives a fresh challenge. The hosted script handles one retry automatically.
400Form check missing.FORM_CHECK_MISSING: the required company_website field is absent. The script injects it during initialization; native and raw API integrations must include it explicitly.
400A valid email is required.email is missing, invalid, or longer than 320 characters
400Message cannot be empty.message is missing or blank
404Unknown form.the form id in the URL doesn't exist
413Payload too large.request body over 64 KB
429Too many requests.more than 20 form requests per minute from one IP, or 60 accepted messages per minute in one inbox
429Too many requests.More than 5 accepted submissions per minute from the same email to one form, even across different IPs. A Retry-After header gives the wait.
429This message was already received.The same sender, subject and message were already accepted by this form within the last minute. The response code is DUPLICATE_MESSAGE. No additional message, notification or quota charge is created.
429Monthly quota reached.The response code is MONTHLY_QUOTA_EXCEEDED, with a Retry-After header until the account’s next monthly quota renewal.
429Inbox cannot accept more messages for review.SPAM_STORAGE_LIMIT: the project already holds 500 pending Spam messages. No additional message is received. Use another contact method; the response includes Retry-After.
415Unsupported content type.Use JSON, URL-encoded form data or multipart form data.

Limits

  • Request bodies are capped at 64 KB.
  • Rate limit: 20 requests per minute per IP, persisted across restarts, answered with a 429 and Retry-After beyond that.
  • Sender limit: 5 accepted submissions per minute per form and email address. Identical submissions from that sender are limited to one per minute; whitespace variations are normalized.
  • Field lengths: name 200, subject 300, message 5,000 characters (longer values are rejected); emails over 320 characters are rejected.

Free includes 50 form submissions per month for one project. Pro includes 1,000 shared messages across ten projects; Plus includes 10,000 across fifty projects. Received messages and replies consume the same quota. Monthly quotas are shared across the account; see pricing.

Related

New here? Start with Getting started. On Pro and Plus, add a contact email address feeding the same inbox. The Spam protection covers keeping junk out of an open endpoint. For a ready-made snippet, try the contact form generator or the framework guides.