Skip to content

Collecting submissions ​

A page needs only the public form_id. API keys never go into browser code.

WaySnippetSuccess message and redirect
Hosted linkhttps://f.formhaus.dev/Xk3d9QpL2aYes
Embed<script src="https://f.formhaus.dev/embed.js" data-form="Xk3d9QpL2a" async></script>Yes
FormhausForm<FormhausForm id="Xk3d9QpL2a" />Yes, unless you pass success, which also skips the redirect
POST JSONPOST https://api.formhaus.dev/f/Xk3d9QpL2aYour code

The page renders the latest version with the default fields and a "Made with Formhaus" footer. ?theme=light or ?theme=dark sets the theme. Unclaimed forms show an "Unverified form" banner and noindex.

Embed ​

The script inserts an iframe of the hosted page after itself and resizes it to the form height.

AttributeValue
data-formRequired. form_id.
data-themelight or dark
data-titleiframe title. Default Form.

A redirect_url opens in the parent page.

FormhausForm ​

FormhausForm loads the form from GET /f/{id}/form, renders it and posts submissions. It shows the form's success message, opens its redirect URL after a submission, and offers "Submit another response" when the form allows it. publish_form returns both files below as react_snippet and vue_snippet.

bash
npm install @formhaus/react @formhaus/core
npm install @formhaus/vue @formhaus/core
tsx
import { FormhausForm } from '@formhaus/react/cloud';
import '@formhaus/core/style.css';

export default function Page() {
  return <FormhausForm id="Xk3d9QpL2a" />;
}
vue
<script setup lang="ts">
import { FormhausForm } from '@formhaus/vue/cloud';
import '@formhaus/core/style.css';
</script>

<template>
  <FormhausForm id="Xk3d9QpL2a" />
</template>

The React entry is a client component. In the Next.js App Router, import it from a server component page such as app/waitlist/page.tsx without a 'use client' directive. For Nuxt, save the Vue file under components/ or pages/.

Callbacks:

tsx
<FormhausForm id="Xk3d9QpL2a" onSuccess={(submission) => console.log(submission.id)} />
vue
<FormhausForm id="Xk3d9QpL2a" @success="(submission) => console.log(submission.id)" />
PropDescription
idRequired. form_id.
apiBaseAPI origin. Default https://api.formhaus.dev.
componentsCustom field components, as in FormRenderer.
onSuccess / @successCalled with { id, values } after a 201.
onErrorCalled with a CloudError (status, code, errors) when loading or submitting fails. A network failure has status 0 and code "network". A request that gets no response within 15 seconds for the definition or 30 seconds for a submission has status 0 and code "timeout".
initialFormA form loaded on the server with fetchForm() from @formhaus/core/cloud. The component renders it without a request.
draftStoreKeeps unsent answers across reloads. sessionDraftStore() from @formhaus/core/cloud keeps them in sessionStorage, until the tab closes and at most 7 days, and leaves out password and file fields. Values of custom field types are kept.
fallback / #fallbackShown while the definition loads.
success / #successReplaces the default message after a submission.

Other FormRenderer props pass through. A 422 maps errors onto fields and focuses the first one. Messages for keys the form does not render are listed in a notice above the form. Every other failure shows a short message in the same notice, which takes focus, with "Try again" for 429, 5xx, network failures and timeouts. Entered values stay. When the definition fails to load for one of these reasons or returns a body that is not JSON, the error has a "Try again" button that loads it again.

FormhausForm sends the version it rendered. If the form was republished and the values no longer pass, it loads the new version, keeps the answers whose field key and type still fit, marks dropped answers with "Please answer again." and asks the respondent to review and submit again.

POST JSON ​

bash
curl -X POST https://api.formhaus.dev/f/Xk3d9QpL2a \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 5b0f6f1e-3c59-4a40-9a57-3c1d6d8f0f11' \
  -d '{"values":{"email":"ada@example.com"},"skippedSteps":[]}'
Body fieldType
valuesObject, field key to value. Missing or null is {}.
skippedStepsArray of step ids. Honored only for steps with skip; their values are dropped.
versionOptional integer, the definition version the form was rendered from.
StatusBody
201{ "id": "<uuid>", "values": {...} }, the values as stored
400{ "error": "malformed_body" } or { "error": "invalid_idempotency_key" }
402{ "error": "limit_reached" }, no upgrade link: the owner learns from email and the dashboard
404{ "error": "not_found" }
409{ "error": "paused" } or { "error": "idempotency_key_reused" }
409{ "error": "form_updated", "version": 4, "errors": {...} }, version is older than the latest and values fail the latest definition. Load the definition again and resubmit. With a valid body the submission is stored under the latest version.
410{ "error": "expired" }
413{ "error": "body_too_large" }, body or values over 64 KB
415{ "error": "unsupported_media_type" }, Content-Type is not application/json
422{ "error": "invalid", "errors": { "<field key>": "<message>" } }
426{ "error": "client_outdated", "minCore": "<version>" }, the definition needs a newer @formhaus/core than the core query parameter
429{ "error": "rate_limited" }
500{ "error": "internal" }
503{ "error": "unavailable" }, validation timed out

Every error body also has message, one sentence on how to fix the request. Branch on error, not on message.

Option values ​

select, radio and multiselect fields take the option value, not the label. A label that is not also a value fails with 422. multiselect takes an array of values.

json
{ "fields": [{ "key": "plan", "type": "select", "label": "Plan", "options": [{ "value": "pro", "label": "Pro plan" }] }] }
json
{ "values": { "plan": "pro" } }

Idempotency-Key ​

Optional header, 1 to 128 characters. Send the same key on every retry of one submission.

RequestResult
New keyThe submission is stored, 201
Same key, same values and skippedStepsReplay: 201 with the first id, nothing new stored, also after the form is paused
Same key, different body409 idempotency_key_reused

Keys are kept per form for 24 hours with a SHA-256 of the normalized body. FormhausForm and the hosted page send one per distinct body.

Rate limits ​

BucketBurstRefill
Per IP201 every 3 seconds
Per network1001 every 0.6 seconds
Per form601 per second

A network is the IPv6 /48 of the sender, or the IPv4 address itself. The buckets run before every other check, so 4xx responses and replays consume tokens. A 429 carries Retry-After in seconds.

CORS ​

Access-Control-Allow-Origin: *. OPTIONS answers 204 with POST, OPTIONS and the headers Content-Type, Idempotency-Key. No cookies.

Anti-spam ​

The hosted page, the embed and FormhausForm send two extra body fields. _fh_hp is a hidden honeypot input. _fh_rt is the render token from the form response. A filled _fh_hp, an invalid _fh_rt, or less than 2 seconds between render and submit returns the usual 201 and stores nothing. Requests without _fh_rt skip the timing check.

Server-side validation ​

The server runs validateSubmission() from @formhaus/core against the latest version.

  • Rules: required, minLength, maxLength, min, max, pattern, and conditional visibility, steps and routes from the definition.
  • Types: email format; number accepts numbers and numeric strings; checkbox and switch need booleans; select, radio and multiselect values must be in options; date is YYYY-MM-DD; datetime is ISO 8601. Strings are at most 10,000 characters.
  • Unknown keys and values of hidden fields are dropped.
  • Stored values are not altered. Zero-width, bidirectional and tag characters stay in the data; the dashboard shows them as \u{XXXX}, and get_submissions flags them with values_contain_hidden_characters.
  • Custom validators and field actions run only in the browser.
  • A pattern that runs over 50 ms fails the submission with 422 or 503.

Public form ​

GET https://api.formhaus.dev/f/{id}/form?core=<version> returns the latest version:

json
{
  "definition": { "id": "contact", "title": "Contact", "submit": { "label": "Send" }, "fields": [] },
  "version": 4,
  "settings": { "successMessage": "Thanks!", "redirectUrl": "https://example.com/thanks", "allowAnother": false },
  "renderToken": "..."
}

core is the @formhaus/core version of the client. FormhausForm sends it on the GET and on the POST. CORS *. Unknown, paused and expired forms answer 404, 409 and 410 with { "error": "not_found" | "paused" | "expired", "message": "..." }.

GET /f/{id}/definition returns the bare definition with the version in the Formhaus-Version and Formhaus-Form-Version headers, for clients older than 0.11.

The _fh_* body keys and a top-level meta key are reserved.

Formhaus is MIT licensed. Legal