BuildBaseBuildBase

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.

MethodPathReturns
GET/api/forms/public/:orgId/:formIdThe form - title, description, collection references, and captcha (see below)
GET/api/forms/public/:orgId/:formId/fieldsThe field schema array from the published collection version
GET/api/forms/public/:orgId/:formId/challengeA 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/uploadsA 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 typeRule
any, required: trueRejected when undefined, null, or ''
numberMust be a number or a numeric string
emailMust match a basic [email protected] pattern
linkMust start with http:// or https://
dateMust parse with Date.parse()
boolMust be true, false, "true" or "false"
fileAn uploadId completed for this form and field, or a list of up to maxFiles of them
text, rich-text, colorStored 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/complete

Then 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 with 400 and the visitor is asked to upload again.
  • The field's size is checked first. A file over maxMb is refused with 413 and code UPLOAD_TOO_LARGE before a byte is sent; the body carries maxBytes, 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 503 with UPLOADS_UNAVAILABLE and a neutral sentence; your storage meter shows why. A server with no direct uploads configured answers 501.

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:

SentError
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:

  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.

A submission held for review is stored without firing form.submitted. The event fires when you approve it.

Next Steps