---
url: https://formhaus.dev/cloud/submissions.md
description: >-
  Put a Formhaus Cloud form on a site with a hosted link, embed.js, FormhausForm
  for React or Vue, or a JSON POST, and the exact submission contract.
---

# 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`.

```bash
npm install @formhaus/react @formhaus/core
npm install @formhaus/vue @formhaus/core
```

::: code-group

```tsx [React]
import { FormhausForm } from '@formhaus/react/cloud';
import '@formhaus/core/style.css';

export default function Page() {
  return <FormhausForm id="Xk3d9QpL2a" />;
}
```

```vue [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:

::: code-group

```tsx [React]
<FormhausForm id="Xk3d9QpL2a" onSuccess={(submission) => console.log(submission.id)} />
```

```vue [Vue]
<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

```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 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.

```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.

| 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: `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.
