How FormJetty works

FormJetty is a hosted form backend. You keep your own front end — a static site, an HTML page, a JavaScript app — and point each form at one FormJetty URL. FormJetty checks the request, stores the submission, holds likely spam for review, emails your confirmed recipients, and calls your signed webhooks.

Three terms appear throughout this page:

  • Workspace — your team's account. Monthly submission limits are counted per workspace.
  • Project — a folder inside a workspace, usually one site or product.
  • Form — one endpoint with its own field rules, recipients, spam mode, and webhooks.

A submission is saved to durable storage before FormJetty confirms it, and email and webhook deliveries are queued in the same database transaction. A confirmed submission can therefore never lose its notification, even if email is slow or a webhook target is down.

The form endpoint

Every form has one permanent, opaque address:

POST https://formjetty.com/f/YOUR_FORM_ID

The form ID is a random public identifier. It reveals nothing about your account or email addresses, so it is safe to ship in public HTML. Copy the exact URL from the form's Installation tab in the dashboard.

  • Only POST is accepted for submissions. OPTIONS is answered for CORS preflight (see CORS).
  • The URL is stable for the life of the form. If you rotate the endpoint from the form's settings, a new ID is issued and the old URL stops working.
  • Paused or archived forms keep their URL but answer 410 until resumed.

Quick start

The smallest working integration is a plain HTML form. Every input needs a name attribute — fields without one are never sent by the browser.

<form action="https://formjetty.com/f/YOUR_FORM_ID" method="POST">
  <input type="email" name="email" required>
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

After a browser submit, the visitor sees your form's hosted success page, or is redirected to your own URL if the form has a redirect configured. For a JSON API flow, send an Accept: application/json header instead:

const response = await fetch('https://formjetty.com/f/YOUR_FORM_ID', {
  method: 'POST',
  headers: { 'Accept': 'application/json' },
  body: new FormData(document.querySelector('form')),
});
const result = await response.json();

Request format

The endpoint accepts three request body formats:

  • application/x-www-form-urlencoded — the default HTML form encoding.
  • multipart/form-data — accepted, but only without files. Any attached file is rejected with file_uploads_not_enabled.
  • application/json — a JSON object of field names to values.

Rules that apply to every submission:

  • The request body must be 1 MB or smaller, or the request is rejected with 413 request_too_large.
  • At least one named field must survive filtering, or validation fails with the field code empty_submission.
  • Values may be strings, numbers, booleans, null, or arrays (for example from <select multiple> or checkbox groups named tags[]).
  • Field names starting with an underscore (_gotcha, _idempotency, and anything else like _csrf) are treated as control fields: they can change behavior but are never stored in the submission.
  • g-recaptcha-response is consumed by the CAPTCHA check and is also never stored.
  • File uploads are not supported in this release.

Stored values are also normalized for display, search, email, and webhooks: strings are trimmed, and any field whose name contains email is lowercased. The original raw payload is kept alongside the normalized one.

HTML and JSON responses

The endpoint answers in one of two modes, chosen per request. You get JSON when any of these is true:

  • the Accept header asks for JSON (for example Accept: application/json),
  • the request body itself is JSON (Content-Type: application/json), or
  • the request carries X-Requested-With: XMLHttpRequest.

Otherwise you get HTML, meant for classic form posts from a browser:

  • On success: a 302 redirect to the form's redirect URL if one is configured, or the form's hosted success page.
  • On failure: a hosted error page carrying the same error status code documented below.

JavaScript integrations should always request JSON. The hosted pages are for visitors, not for parsing.

Success responses

An accepted submission returns 201 Created:

{
  "ok": true,
  "code": "submission_accepted",
  "message": "Your form was sent.",
  "submission_id": "01K3F8YV6M6P0Q8Z6E0W9T3JD2",
  "request_id": "9f5c2c1e-4e0a-4b53-9a52-1f6f2f8f2ab1"
}
  • message is the form's configurable JSON success text — display it rather than hardcoding your own.
  • submission_id is the stable public ID of the stored submission; request_id identifies this intake request. Both are safe to log and useful in support requests.
  • A submission held as likely spam returns the same 201 submission_accepted response. Spam is filed to the form's spam inbox without notifying anyone; the response deliberately does not reveal the classification to the sender.

A repeated submission with a known idempotency key returns 200 OK with "code": "submission_already_accepted" and the submission_id of the original submission (see duplicate protection).

Error responses

Every JSON error has the same shape. error.fields is present when specific fields failed, and empty otherwise:

{
  "ok": false,
  "error": {
    "code": "validation_failed",
    "message": "Check the marked fields and try again.",
    "fields": [
      {
        "field": "email",
        "code": "invalid_email",
        "message": "The email field must be a valid email address."
      }
    ]
  }
}

Top-level result codes:

HTTPcodeMeaning
201submission_acceptedStored. Also returned for submissions quietly held as spam.
200submission_already_acceptedThe idempotency key was already used; the original submission_id is returned. Not an error — ok is true.
403origin_not_allowedThe form restricts submissions to one domain and the request came from another site.
410form_inactiveThe form is paused or archived.
410project_archivedThe form's project is archived.
413request_too_largeThe request body exceeds 1 MB.
422validation_failedOne or more fields failed the form's rules. Inspect error.fields.
422file_uploads_not_enabledThe request included a file. Remove attachments and resend.
422recaptcha_required, recaptcha_failed, recaptcha_action_mismatch, recaptcha_score_too_low, recaptcha_hostname_mismatchThe reCAPTCHA check did not pass. Obtain a fresh token and retry (see reCAPTCHA).
423form_suspendedThe form or its workspace is suspended by the service. Do not retry automatically.
429rate_limit_exceededMore than the per-sender or per-form limit. A Retry-After header says how many seconds to wait before retrying.
429monthly_limit_reachedThe workspace used its monthly accepted-submission quota. Retrying within the same month will not help.
503recaptcha_not_configured, recaptcha_unavailableThe CAPTCHA could not be verified for reasons on the service side. Safe to retry later.

Errors that relate to timing (429 rate_limit_exceeded) include a Retry-After header in both JSON and HTML modes. All error responses carry the CORS headers described below, so browser JavaScript can always read them.

Server-side field rules

Each form can define per-field rules in the dashboard (the Field rules tab). Rules run on the server on every submission, so they hold even when someone bypasses your front-end validation. A field can be marked required, given a minimum and maximum, a custom error message, and one of these types:

TypeServer check
textAny string. Min/max limit the character length.
emailA valid RFC email address.
numberNumeric. Min/max limit the value.
telephone7–30 characters of digits, spaces, and + ( ) . -.
urlA valid http or https URL.
date, datetimeA parseable date or date-time string.
booleantrue, false, 1, 0, "1", or "0" — checkbox-friendly.

New forms start with two default rules — a required email field of type email and a required message text field with a 10,000-character maximum — which the form owner can change or remove at any time.

Failures come back as 422 validation_failed with one entry per failed field in error.fields. Field-level codes:

Field codeMeaning
empty_submissionNo named fields arrived at all (field is null).
requiredA required field is missing or empty.
invalid_email, invalid_number, invalid_url, invalid_date, invalid_booleanThe value does not match the field's type.
too_short_or_small, too_long_or_largeThe value is outside the field's minimum or maximum.
invalidAny other rule failure (for example the telephone pattern).

Each entry's message is the form owner's custom error text when one is set, so prefer showing it verbatim. Fields with no rules are accepted as-is. If the form's automatic confirmation reply is enabled, its email field (usually email) is always required and must be a valid address, even without an explicit rule.

The dashboard's rule tester runs these same checks as a dry run: it validates a sample payload without storing anything, sending mail, or calling webhooks.

Special fields and headers

NameKindPurpose
_gotchabody fieldHoneypot. Leave it in your form, hidden from people; anything non-empty marks the submission as spam. Never stored.
_idempotencybody fieldIdempotency key as a form field, for plain HTML forms that cannot set headers.
Idempotency-KeyheaderIdempotency key as a header — the preferred form for API calls. Takes precedence over _idempotency.
g-recaptcha-responsebody fieldThe reCAPTCHA v3 token, required when the form has reCAPTCHA enabled. Never stored.
_anythingbody fieldAny other underscore-prefixed field is silently dropped and never stored.

A recommended honeypot placement — visually hidden, but present in the DOM for bots to fill:

<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
       style="position:absolute; left:-5000px" aria-hidden="true">

Duplicate protection

To protect against double submits and network retries, send a unique idempotency key with each logical submission — via the Idempotency-Key header or the _idempotency field. Keys are compared per form and truncated to 128 characters.

  • The first request with a key stores the submission normally and returns 201.
  • Any later request with the same key stores nothing, sends nothing, and returns 200 with "code": "submission_already_accepted" and the original submission_id.

Generate a fresh key per submission attempt series (for example a UUID created when the visitor opens the form), and reuse that same key for retries of that submission. Without a key, every request is treated as a new submission.

Spam filtering

Every accepted request is scored between 0 and 1 by a set of transparent rules before delivery:

  • A filled _gotcha honeypot immediately scores as spam.
  • Three or more links in the message text raise the score.
  • Known spam phrases and long runs of a repeated character raise the score.

The form's spam mode sets the threshold at which a submission is held: relaxed (0.9), standard (0.7, the default), or aggressive (0.5). A held submission:

  • still returns 201 submission_accepted, so senders — and bots — cannot tell they were filtered;
  • is stored in the form's spam inbox with its score and reasons, where a human can review it and mark it as not spam;
  • triggers no notification emails and no webhooks;
  • does not count against the workspace's monthly limit.

reCAPTCHA v3

Forms may additionally require Google reCAPTCHA v3. When it is enabled, every submission must include a token in g-recaptcha-response, or the request fails with 422 recaptcha_required. The token must:

  • verify successfully with Google,
  • have been generated with the action submit,
  • meet or beat the form's configured score threshold, and
  • come from the form's allowed domain (or a subdomain of it), when one is set.

Generating a token client-side:

<script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY"></script>
<script>
  const token = await grecaptcha.execute('YOUR_SITE_KEY', { action: 'submit' });
  formData.set('g-recaptcha-response', token);
</script>

Tokens are single-use and expire quickly, so request one at submit time, not at page load. If verification cannot run at all, the endpoint answers 503 (recaptcha_unavailable or recaptcha_not_configured) and it is safe to retry later. The JavaScript helper handles token fetching automatically when you set data-recaptcha-sitekey.

CORS and allowed domains

The endpoint is built to be called from browsers on other origins:

  • OPTIONS https://formjetty.com/f/YOUR_FORM_ID answers preflight with 204.
  • Allowed methods are POST, OPTIONS; allowed request headers are Accept, Content-Type, Idempotency-Key, and X-Requested-With; preflight results may be cached for 600 seconds.
  • With no domain restriction, Access-Control-Allow-Origin is *. With a restriction, the request's origin is echoed so the browser can read the response — including error responses such as 403 origin_not_allowed.

When a form sets an allowed domain, the submission's Origin (or, failing that, Referer) host must be that domain or a subdomain of it; otherwise the endpoint answers 403 origin_not_allowed. Requests that carry neither header — such as curl or server-to-server calls — are not blocked by this check, so treat the allowed domain as a browser-abuse control, not an authentication mechanism.

Rate and usage limits

LimitDefaultWhen exceeded
Request size1 MB per request413 request_too_large
Per-sender rate10 posts per minute and 60 per hour from one network, per form429 rate_limit_exceeded with Retry-After: 60 or 3600
reCAPTCHA checks120 token checks per minute per form, when reCAPTCHA is on429 rate_limit_exceeded with Retry-After: 60
Per-form rate20 submissions per minute429 rate_limit_exceeded with Retry-After: 60
Monthly accepted submissions500 per workspace on the free plan429 monthly_limit_reached
Submission history12 months on the free plan—

Spam-held submissions never count toward the monthly limit. The per-sender limit counts every post from one network; the per-form limit counts only posts that pass validation and reCAPTCHA, so malformed traffic cannot crowd out real visitors.

The JavaScript helper

For sites that want inline success and error handling without writing fetch code, FormJetty ships a small dependency-free helper at https://formjetty.com/js/formjetty.js. It intercepts the submit, posts via fetch in JSON mode, disables the submit button while sending, renders per-field errors with aria-invalid and matching aria-describedby text, and updates a status element.

<script src="https://formjetty.com/js/formjetty.js" defer></script>

<form action="https://formjetty.com/f/YOUR_FORM_ID" data-formjetty data-formjetty-reset>
  <input name="email" type="email">
  <textarea name="message"></textarea>
  <button type="submit">Send</button>
  <p data-formjetty-status aria-live="polite"></p>
</form>
AttributeWhereEffect
data-formjettyformOpts the form into the helper.
data-formjetty-resetformClears the fields after a successful send.
data-recaptcha-sitekeyformFetches a reCAPTCHA v3 token with action submit before posting. Load Google's api.js yourself.
data-formjetty-statusany element in the formReceives the success or error message text.

The helper also emits bubbling DOM events carrying the parsed JSON response in event.detail: formjetty:success and formjetty:error. Programmatic use is available as window.Formjetty.submit(formElement), which returns a promise of the parsed response.

Email notifications

Delivery settings live in the dashboard and need nothing from your front end, but two behaviors are visible to it:

  • Each form notifies up to 10 recipients. Every recipient must click a one-use confirmation link (valid for seven days) before receiving submissions, and can be paused or resumed individually.
  • The notification's Reply-To can be mapped to a submitted field (usually email), so keep using conventional field names.
  • An optional confirmation reply can be sent to the person who submitted the form. Enabling it makes the form's email field required and valid, and requires reCAPTCHA on the form so the auto-reply cannot be abused to send mail to strangers. Confirmations are never sent for spam-held submissions.

Notifications are queued after the submission is stored and retried on failure; delivery state per recipient is visible on each submission in the dashboard.

Signed webhooks

Each form can call multiple HTTPS webhook endpoints. On every accepted (non-spam) submission, each active endpoint receives a POST with a JSON body:

{
  "id": "5c3a1d0e-9e2f-4b1a-8f6d-2f1f4f6a9c11",
  "event": "submission.created",
  "created_at": "2026-08-14T09:30:12+00:00",
  "data": {
    "submission": {
      "id": "01K3F8YV6M…",
      "request_id": "9f5c2c1e-…",
      "status": "accepted",
      "fields": { "email": "[email protected]", "message": "Hello" }
    },
    "form": { "id": "YOUR_FORM_ID", "name": "Contact form" },
    "project": { "id": 42, "name": "Marketing site" }
  }
}

data.submission.fields contains the normalized field values. A webhook.test event with a short message body can be sent from the dashboard to try a receiver.

Request headers

HeaderValue
User-AgentFormjetty-Webhook/1.0
X-Formjetty-DeliveryUnique delivery ID — use it to deduplicate retries.
X-Formjetty-Eventsubmission.created or webhook.test
X-Formjetty-TimestampUnix timestamp (seconds) when the delivery was signed.
X-Formjetty-Signaturev1= followed by a lowercase hex HMAC (see below).

Verifying the signature

Each endpoint has its own signing secret, shown once when the endpoint is created and rotatable at any time. To verify a delivery:

  1. Read the raw request body — do not re-serialize the parsed JSON, because key order and whitespace must match exactly.
  2. Compute HMAC_SHA256(timestamp + "." + raw_body, secret) using the value of X-Formjetty-Timestamp.
  3. Compare it in constant time against the hex digest after v1= in X-Formjetty-Signature.
  4. Reject stale timestamps (for example older than five minutes) to prevent replay.
// Node.js example
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, headers, secret) {
  const timestamp = headers['x-formjetty-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  const given = (headers['x-formjetty-signature'] || '').replace(/^v1=/, '');
  return given.length === expected.length &&
    timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

Delivery behavior

  • Answer with any 2xx status within 10 seconds. Redirects are not followed and count as failures.
  • Failed deliveries are retried up to 5 attempts with growing waits of about 1, 5, 15, and 60 minutes.
  • Targets must be public HTTPS URLs — private and internal addresses are refused.
  • Every attempt is logged with its status and response, and any delivery can be replayed from the dashboard.

Notes for AI coding agents

If you are an AI agent generating a form or client that submits to FormJetty, this checklist covers the whole contract:

  1. Post to the exact endpoint URL the user gives you, of the shape https://formjetty.com/f/FORM_ID. Never invent a form ID.
  2. Give every input a name. Use conventional names — especially email for the sender's address, since Reply-To and confirmation replies key off it. Field names containing email are lowercased server-side.
  3. Do not use underscore-prefixed names for real data — they are dropped. Do not add file inputs — files are rejected.
  4. Add the honeypot: a visually hidden text input named _gotcha that humans never fill.
  5. Prefer JSON mode in JavaScript: send Accept: application/json, treat response.ok && body.ok as success, and render body.error.fields[].message next to the matching inputs on 422.
  6. Use the exact response messages: message on success and error.message / field messages on failure are configured by the form owner.
  7. Handle retries safely: send an Idempotency-Key (or _idempotency field) per submission; treat 200 submission_already_accepted as success. Honor Retry-After on 429 rate_limit_exceeded; do not auto-retry monthly_limit_reached, 410, or 423.
  8. Do not fight the spam filter: a 201 may still mean the submission was held for review — that is intended behavior, not a bug to work around.
  9. If reCAPTCHA is enabled, fetch a v3 token at submit time with action submit and send it as g-recaptcha-response — or simply use the hosted helper at https://formjetty.com/js/formjetty.js with data-recaptcha-sitekey.
  10. Keep requests under 1 MB and expect field validation errors from the server even if your client-side validation passed — the form owner's rules run server-side.

A minimal, fully correct static-site integration is the JavaScript helper example plus the hidden honeypot field.