Collecting submissions
A page needs only the public form_id. API keys never go into browser code.
| Way | Snippet | Success message and redirect |
|---|---|---|
| Hosted link | https://f.formhaus.dev/Xk3d9QpL2a | Yes |
| 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 JSON | POST https://api.formhaus.dev/f/Xk3d9QpL2a | Your code |
Hosted link
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.
| Attribute | Value |
|---|---|
data-form | Required. form_id. |
data-theme | light or dark |
data-title | iframe 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.
npm install @formhaus/react @formhaus/core
npm install @formhaus/vue @formhaus/coreimport { FormhausForm } from '@formhaus/react/cloud';
import '@formhaus/core/style.css';
export default function Page() {
return <FormhausForm id="Xk3d9QpL2a" />;
}<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:
<FormhausForm id="Xk3d9QpL2a" onSuccess={(submission) => console.log(submission.id)} /><FormhausForm id="Xk3d9QpL2a" @success="(submission) => console.log(submission.id)" />| Prop | Description |
|---|---|
id | Required. form_id. |
apiBase | API origin. Default https://api.formhaus.dev. |
components | Custom field components, as in FormRenderer. |
onSuccess / @success | Called with { id, values } after a 201. |
onError | Called 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". |
initialForm | A form loaded on the server with fetchForm() from @formhaus/core/cloud. The component renders it without a request. |
draftStore | Keeps 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 / #fallback | Shown while the definition loads. |
success / #success | Replaces 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
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 field | Type |
|---|---|
values | Object, field key to value. Missing or null is {}. |
skippedSteps | Array of step ids. Honored only for steps with skip; their values are dropped. |
version | Optional integer, the definition version the form was rendered from. |
| Status | Body |
|---|---|
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.
{ "fields": [{ "key": "plan", "type": "select", "label": "Plan", "options": [{ "value": "pro", "label": "Pro plan" }] }] }{ "values": { "plan": "pro" } }Idempotency-Key
Optional header, 1 to 128 characters. Send the same key on every retry of one submission.
| Request | Result |
|---|---|
| New key | The submission is stored, 201 |
Same key, same values and skippedSteps | Replay: 201 with the first id, nothing new stored, also after the form is paused |
| Same key, different body | 409 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
| Bucket | Burst | Refill |
|---|---|---|
| Per IP | 20 | 1 every 3 seconds |
| Per network | 100 | 1 every 0.6 seconds |
| Per form | 60 | 1 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:
emailformat;numberaccepts numbers and numeric strings;checkboxandswitchneed booleans;select,radioandmultiselectvalues must be inoptions;dateisYYYY-MM-DD;datetimeis 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}, andget_submissionsflags them withvalues_contain_hidden_characters. - Custom
validatorsand field actions run only in the browser. - A
patternthat runs over 50 ms fails the submission with422or503.
Public form
GET https://api.formhaus.dev/f/{id}/form?core=<version> returns the latest version:
{
"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.