# Overview

Build a form in the console, submit to it from anywhere without a token, and trigger a workflow.

A form is a schema plus a public endpoint. You define the fields in the console,
the submission is stored as a collection record, and `form.submitted` fires as a
workflow trigger.

Forms are the only module besides public plans with **unauthenticated**
endpoints. A submission needs no API token, which is what makes a form embeddable
on a marketing site.

```bash
curl -X POST \
  https://api.console.buildbase.app/api/forms/public/$ORG_ID/$FORM_ID/submit \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","message":"Interested in the Scale plan"}'
```

```json
{ "success": true, "message": "Form submitted successfully" }
```

> **Note:**
  Create the form in the BuildBase console under **Forms**. It is backed by a
  collection, so a collection and a published version must exist first. The
  console creates both when you build a form from scratch.


## The three public endpoints

None of these require an `Authorization` header.

| Method | Path                                      | Returns                                                      |
| ------ | ----------------------------------------- | ------------------------------------------------------------ |
| `GET`  | `/api/forms/public/:orgId/:formId`        | The form document — title, settings, collection references   |
| `GET`  | `/api/forms/public/:orgId/:formId/fields` | The field schema array from the published collection version |
| `POST` | `/api/forms/public/:orgId/:formId/submit` | `{ success, message }`                                       |

`:orgId` must be a valid ObjectId or the request is rejected before lookup. An
unknown `formId` returns `404` with
`{"error": true, "message": "Form with id ... not found"}`.

## Submitting

The request body **is** the data object. Keys are field slugs — there is no
wrapper:

```json
{ "email": "alice@example.com", "plan": "scale", "seats": 12 }
```

Each submission creates a collection record carrying `collectionId`,
`versionId`, `formId`, and your `data`, then emits `form.submitted` with the
form ID, the new record ID, and the submitted data.

## Validation

The server validates the payload against the published field schema before
writing anything.

A collection field is one of eight types. Only four are validated on submit:

| Field type                   | Rule                                          |
| ---------------------------- | --------------------------------------------- |
| any, `required: true`        | Rejected when `undefined`, `null`, or `''`    |
| `number`                     | Must be a number or a numeric string          |
| `email`                      | Must match a basic `local@domain.tld` pattern |
| `link`                       | Must start with `http://` or `https://`       |
| `date`                       | Must parse with `Date.parse()`                |
| `text`, `rich-text`, `color` | Stored as sent, no format check               |
| `bool`                       | **Not validated — see below**                 |

Empty optional values skip every check, so a rule only applies to a field the
submitter actually filled in.

> **Note:**
  **Boolean fields are not validated.** The validator branches on the string
  `boolean`, but the type's stored value is `bool`, so the branch never runs.
  Any value at all — `"yes"`, `42`, an object — is accepted into a `bool` field
  and stored verbatim.

Coerce booleans client-side, and treat the stored value as untrusted when you
read records back.



Note that `email` and `link` only validate when the submitted value is a
string. A number or object sent for those fields skips the format check the
same way.

A failure returns `400` with every error at once, not just the first:

```json
{
  "success": false,
  "message": "Validation failed",
  "errors": ["Email must be a valid email", "Seats is required"]
}
```

Note this uses `success: false`, unlike most API errors which use
`error: true`. Branch on the HTTP status rather than the body shape.

> **Note:**
  The public submit endpoint has **no CAPTCHA and no spam filtering**. The only
  limit in front of it is the global API rate limiter — 500 requests per 3
  seconds per IP — which is a flood guard, not bot protection. A single client
  can submit far faster than any human and stay well under it.

Put your own bot protection in front of a publicly embedded form, or proxy
submissions through your backend.



## Triggering a workflow

`form.submitted` is a workflow trigger under the Forms category, and one of
86+ events in
the [system event catalog](/webhooks/overview). A common shape:

1. Visitor submits the form.
2. Record is written to the collection.
3. `form.submitted` fires.
4. A workflow sends a confirmation email and posts to Slack.

The event payload carries `formId`, `recordId`, and the full `data` object, so
merge tags in the workflow can address submitted fields directly.

## Next Steps

- [Custom forms](/forms/custom-forms) — render your own UI against the field schema.
- [Workflows](/workflows/overview) — act on `form.submitted`.
- [Admin API](/reference/admin-api) — read submissions back through the collections API.
