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.
curl -X POST \
https://api.console.buildbase.app/api/forms/public/$ORG_ID/$FORM_ID/submit \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","message":"Interested in the Scale plan"}'{ "success": true, "message": "Form submitted successfully" }Before you start
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 public endpoints
None of these require an Authorization header.
| Method | Path | Returns |
|---|---|---|
GET | /api/forms/public/:orgId/:formId | The form - title, description, collection references, and captcha (see below) |
GET | /api/forms/public/:orgId/:formId/fields | The field schema array from the published collection version |
GET | /api/forms/public/:orgId/:formId/challenge | A built-in captcha challenge, only for a form that uses the built-in check |
POST | /api/forms/public/:orgId/:formId/submit | { success, message } |
POST | /api/forms/public/:orgId/:formId/uploads | A signed URL for one file field's file - see File fields |
POST | /api/forms/public/:orgId/:formId/uploads/:id/complete | { uploadId, name, size, mimeType } |
captcha is null when the form does not require one. Otherwise it says which
widget to render, with a public site key for the third-party providers:
{ "widget": "turnstile", "siteKey": "0x4AAAAAAA..." }widget is one of turnstile, hcaptcha, recaptcha or pow (the built-in
check, which has no site key). The form's spam rules are never included.
: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:
{ "email": "[email protected]", "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 nine types. Six have a format check 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 [email protected] pattern |
link | Must start with http:// or https:// |
date | Must parse with Date.parse() |
bool | Must be true, false, "true" or "false" |
file | An uploadId completed for this form and field, or a list of up to maxFiles of them |
text, rich-text, color | Stored as sent, no format check |
Empty optional values skip every check, so a rule only applies to a field the submitter actually filled in.
A bool field accepts the string forms because a form posted as FormData
sends everything as a string. That also means "true" rather than true is a
normal thing to find in stored records - coerce on read.
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:
{
"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
Public forms are protected from bots and spam without code: a hidden bot trap, a duplicate guard and rate limits are always on, and a captcha, content rules, a review queue and a daily cap are switches on each form. See Spam protection.
The always-on rate limits are 10 submissions per minute and 20 per hour from
one IP, and 5,000 per day for your whole organization. Past any of them the
endpoint returns 429 Too many submissions.
File fields
A file field takes an image from the
person filling in the form. The file goes straight to storage, never through
the submit request, in three steps - the same ones an
asset upload takes:
# 1. Declare it for one field. Nothing is sent yet, so a file the field cannot
# take is refused here.
curl -X POST https://api.console.buildbase.app/api/forms/public/$ORG_ID/$FORM_ID/uploads \
-H "Content-Type: application/json" \
-d '{"field":"receipt","name":"receipt.png","size":900000,"contentType":"image/png"}'
# 2. PUT the bytes to the URL it returned, with exactly the headers it returned.
curl -X PUT "$URL" -H "Content-Type: image/png" ... --data-binary @receipt.png
# 3. Complete it.
curl -X POST https://api.console.buildbase.app/api/forms/public/$ORG_ID/$FORM_ID/uploads/$UPLOAD_ID/completeThen submit the form with the uploadId as the field's value:
{ "name": "Ada Lovelace", "receipt": "6ace3b0e898d27ef4b7a6a21" }The record stores the file's assetId, name, size and mimeType, not the
upload id. The hosted form does all of this for you; the steps matter when you
build your own.
- An upload is used once. It attaches to one submission of the form and
field it was made for. A second submission with the same
uploadId, or one made for another field, is refused with400and the visitor is asked to upload again. - The field's size is checked first. A file over
maxMbis refused with413and codeUPLOAD_TOO_LARGEbefore a byte is sent; the body carriesmaxBytes, the field's limit in bytes. - Files count toward your storage. A visitor's file is your file, stored privately in your assets. A file uploaded but never submitted is removed a day later, and its storage with it.
- Your plan is not the visitor's problem. When your storage is full, the
intent answers
503withUPLOADS_UNAVAILABLEand a neutral sentence; your storage meter shows why. A server with no direct uploads configured answers501.
Uploads have their own budget, separate from submissions: 20 per hour from one IP and 5,000 per day for your organization.
Several files in one field
A field's maxFiles, from 1 to 10, is how many files it takes. Upload each
file on its own, then send the field a list of their ids:
{
"name": "Grace Hopper",
"photos": ["6ace3b0e898d27ef4b7a6a21", "6ace3b0e898d27ef4b7a6a22"]
}A field that takes more than one file stores a list of files, even when one
was sent; a field that takes one stores a single file and takes a single id,
not a list. These are refused with 400 and Validation failed:
| Sent | Error |
|---|---|
| Something other than upload ids, or a list to a field that takes one file | <Field> must be an uploaded file |
More ids than maxFiles | <Field> takes up to N files |
| The same id twice | <Field> has the same file twice |
| An id that was not completed, or was made for another form or field, or already used | <Field>: the file was not found. Upload it again. |
Triggering a workflow
form.submitted is a workflow trigger under the Forms category, and one of
129 events in
the system event catalog. A common shape:
- Visitor submits the form.
- Record is written to the collection.
form.submittedfires.- 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.
A submission held for review is
stored without firing form.submitted. The event fires when you approve it.
Next Steps
- Spam protection - captcha, content rules and the review queue.
- Share and embed - the hosted link and the iframe embed.
- Custom forms - render your own UI against the field schema.
- Workflows - act on
form.submitted. - Admin API - read submissions back through the collections API.