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
POSTis accepted for submissions.OPTIONSis 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
410until 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 withfile_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 namedtags[]). - 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-responseis 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
Acceptheader asks for JSON (for exampleAccept: 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
302redirect 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"
}
messageis the form's configurable JSON success text — display it rather than hardcoding your own.submission_idis the stable public ID of the stored submission;request_ididentifies this intake request. Both are safe to log and useful in support requests.- A submission held as likely spam returns the same
201 submission_acceptedresponse. 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:
| HTTP | code | Meaning |
|---|---|---|
| 201 | submission_accepted | Stored. Also returned for submissions quietly held as spam. |
| 200 | submission_already_accepted | The idempotency key was already used; the original submission_id is returned. Not an error — ok is true. |
| 403 | origin_not_allowed | The form restricts submissions to one domain and the request came from another site. |
| 410 | form_inactive | The form is paused or archived. |
| 410 | project_archived | The form's project is archived. |
| 413 | request_too_large | The request body exceeds 1 MB. |
| 422 | validation_failed | One or more fields failed the form's rules. Inspect error.fields. |
| 422 | file_uploads_not_enabled | The request included a file. Remove attachments and resend. |
| 422 | recaptcha_required, recaptcha_failed, recaptcha_action_mismatch, recaptcha_score_too_low, recaptcha_hostname_mismatch | The reCAPTCHA check did not pass. Obtain a fresh token and retry (see reCAPTCHA). |
| 423 | form_suspended | The form or its workspace is suspended by the service. Do not retry automatically. |
| 429 | rate_limit_exceeded | More than the per-sender or per-form limit. A Retry-After header says how many seconds to wait before retrying. |
| 429 | monthly_limit_reached | The workspace used its monthly accepted-submission quota. Retrying within the same month will not help. |
| 503 | recaptcha_not_configured, recaptcha_unavailable | The 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:
| Type | Server check |
|---|---|
text | Any string. Min/max limit the character length. |
email | A valid RFC email address. |
number | Numeric. Min/max limit the value. |
telephone | 7–30 characters of digits, spaces, and + ( ) . -. |
url | A valid http or https URL. |
date, datetime | A parseable date or date-time string. |
boolean | true, 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 code | Meaning |
|---|---|
empty_submission | No named fields arrived at all (field is null). |
required | A required field is missing or empty. |
invalid_email, invalid_number, invalid_url, invalid_date, invalid_boolean | The value does not match the field's type. |
too_short_or_small, too_long_or_large | The value is outside the field's minimum or maximum. |
invalid | Any 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
| Name | Kind | Purpose |
|---|---|---|
_gotcha | body field | Honeypot. Leave it in your form, hidden from people; anything non-empty marks the submission as spam. Never stored. |
_idempotency | body field | Idempotency key as a form field, for plain HTML forms that cannot set headers. |
Idempotency-Key | header | Idempotency key as a header — the preferred form for API calls. Takes precedence over _idempotency. |
g-recaptcha-response | body field | The reCAPTCHA v3 token, required when the form has reCAPTCHA enabled. Never stored. |
_anything | body field | Any 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
200with"code": "submission_already_accepted"and the originalsubmission_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
_gotchahoneypot 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_IDanswers preflight with204.- Allowed methods are
POST, OPTIONS; allowed request headers areAccept,Content-Type,Idempotency-Key, andX-Requested-With; preflight results may be cached for 600 seconds. - With no domain restriction,
Access-Control-Allow-Originis*. With a restriction, the request's origin is echoed so the browser can read the response — including error responses such as403 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
| Limit | Default | When exceeded |
|---|---|---|
| Request size | 1 MB per request | 413 request_too_large |
| Per-sender rate | 10 posts per minute and 60 per hour from one network, per form | 429 rate_limit_exceeded with Retry-After: 60 or 3600 |
| reCAPTCHA checks | 120 token checks per minute per form, when reCAPTCHA is on | 429 rate_limit_exceeded with Retry-After: 60 |
| Per-form rate | 20 submissions per minute | 429 rate_limit_exceeded with Retry-After: 60 |
| Monthly accepted submissions | 500 per workspace on the free plan | 429 monthly_limit_reached |
| Submission history | 12 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>
| Attribute | Where | Effect |
|---|---|---|
data-formjetty | form | Opts the form into the helper. |
data-formjetty-reset | form | Clears the fields after a successful send. |
data-recaptcha-sitekey | form | Fetches a reCAPTCHA v3 token with action submit before posting. Load Google's api.js yourself. |
data-formjetty-status | any element in the form | Receives 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
| Header | Value |
|---|---|
User-Agent | Formjetty-Webhook/1.0 |
X-Formjetty-Delivery | Unique delivery ID — use it to deduplicate retries. |
X-Formjetty-Event | submission.created or webhook.test |
X-Formjetty-Timestamp | Unix timestamp (seconds) when the delivery was signed. |
X-Formjetty-Signature | v1= 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:
- Read the raw request body — do not re-serialize the parsed JSON, because key order and whitespace must match exactly.
- Compute
HMAC_SHA256(timestamp + "." + raw_body, secret)using the value ofX-Formjetty-Timestamp. - Compare it in constant time against the hex digest after
v1=inX-Formjetty-Signature. - 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
2xxstatus 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:
- Post to the exact endpoint URL the user gives you, of the shape
https://formjetty.com/f/FORM_ID. Never invent a form ID. - Give every input a
name. Use conventional names — especiallyemailfor the sender's address, since Reply-To and confirmation replies key off it. Field names containingemailare lowercased server-side. - Do not use underscore-prefixed names for real data — they are dropped. Do not add file inputs — files are rejected.
- Add the honeypot: a visually hidden text input named
_gotchathat humans never fill. - Prefer JSON mode in JavaScript: send
Accept: application/json, treatresponse.ok && body.okas success, and renderbody.error.fields[].messagenext to the matching inputs on422. - Use the exact response messages:
messageon success anderror.message/ field messages on failure are configured by the form owner. - Handle retries safely: send an
Idempotency-Key(or_idempotencyfield) per submission; treat200 submission_already_acceptedas success. HonorRetry-Afteron429 rate_limit_exceeded; do not auto-retrymonthly_limit_reached,410, or423. - Do not fight the spam filter: a
201may still mean the submission was held for review — that is intended behavior, not a bug to work around. - If reCAPTCHA is enabled, fetch a v3 token at submit time with action
submitand send it asg-recaptcha-response— or simply use the hosted helper athttps://formjetty.com/js/formjetty.jswithdata-recaptcha-sitekey. - 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.