Forms: newsletter boxes, waitlists and lead capture on any page
A form is something a reader fills in and sends: an email box at the foot of a post, a waitlist on a pricing page, a "request a demo" form, a survey. You build the form once, place it on as many pages as you like, and every response lands in one inbox that you can read, export and delete.
Forms work on every reader address: our own (forumulate.com/p/…), a custom domain, and a custom domain mounted under a path (acme.com/docs). They also work in a browser with JavaScript turned off.
Forms are on every plan, the free plan included: every field type, branching, scoring and integration. The one thing that needs a paid plan is taking a payment with a form.
The two halves: the form and the block
The form is stored on the server. It holds the fields, what the button says, what happens after sending, the consent text and who is emailed. A submission is checked against this stored definition and nothing else. Answers to fields the definition does not name are thrown away, so a reader who edits the page's HTML cannot add a field, drop a required, or widen a dropdown.
The block places the form on a page. It names the form by its id and keeps a copy of the fields so the page can render without a second request. On docs pages, docs landing pages, blog posts and changelog entries, the reader draws the stored definition instead of the copy, so an edit in the console shows up on every page that carries the form without republishing. A block whose form was deleted says so. A form that is closed shows that it is not taking responses.
Building a form
There are two places to build one:
Console → Forms (
/o/{orgId}/forms). Here you create, edit and delete forms, read their responses, and export them.The editor. Type
/form, then choose an existing form or "Create a new form" in the block's inspector. "Edit fields and settings" changes the stored form. The inspector tells you this changes the form on every page that carries it.
Field types
Type | Renders as | Checked on the server |
|---|---|---|
| one-line input | length ( |
|
| is an address; the first one is the DSAR key |
| multi-line input | length, |
| dropdown | is one of |
| one choice from a group | is one of |
| a single tick box |
|
|
| is a number within |
|
| is an |
|
| digits, spaces, |
|
| a real calendar date, |
| not shown | the page's value, or the field's own |
| several tick boxes | each one of |
| a box that must be ticked | always required; stores the label agreed to |
| stars, 1 to | a whole number in range |
| a row of numbers, | a whole number in range; |
| Yes / No | stored as |
|
|
|
| a country picker, names in the reader's language | an ISO 3166-1 code, stored upper case |
| put the options in order (up to 12) | each option once; |
| a grid: one column ( | listed answers only; |
| street, line 2, town, region, postcode, country |
|
| a heading ( | never answered |
| a titled group of the questions under it | never answered |
| a page break; its | never answered |
Three more types, file, signature (a drawn signature, stored as a PNG) and video (a recorded or picked video or voice answer, mp4 or webm), take uploads. They exist only on the forms that have an upload door, the contact page and the careers apply form (Contact spaces). A page form refuses them with a 422, because a control that takes a file and sends nothing would be worse than none.
Every answer is stored as text or a list of text, so the inbox, the CSV, the webhook and a data-protection export read the new kinds with no new shape: a ranking is the ordered list, a matrix one Row: Column line per row, an address its lines.
Every field has a name. It is the key in submissions, the CSV column and the API, and it must be lower-case letters, digits and _, starting with a letter, at most 40 characters, and unique within the form. Names starting with _ are reserved for the plumbing below. Visible fields also need a label. Optional settings are placeholder, help (a line under the control), required, options (for select and radio), min/max (the value range for numbers, the length range for text) and pattern. Later settings: value (the starting answer, or a hidden field's value), prefill, showIf, rows (a matrix), optionImages (a picture per option on a radio or multiselect, loaded through our image proxy), points (scoring) and hideWhenKnown, all described below.
A pattern is a regular expression that the whole answer must match. Because your pattern runs against a stranger's input on our servers, it is limited to 200 characters and only ever runs against the first 500 characters of an answer. Patterns with nested quantifiers like (a+)+ are refused, and so are backreferences, because both can make matching take forever.
The limits on a form's definition:
What | Limit |
|---|---|
Form name | 1–120 characters |
Fields | 1–30 |
Field | 40 characters |
| 200 characters each |
| 500 characters |
| 50 per field, each 1–120 characters, unique |
| 200 characters |
A | 500 characters |
Submit button label | 60 characters |
Thank-you message | 1,000 characters |
Redirect | 2,048 characters |
Consent text | 1–500 characters |
Notify addresses | 10 |
After sending
Thank-you message. Shown in place of the form. The default is "Thanks — your response was sent."
Redirect. Send the reader somewhere instead. This is either a page on your own site (
/thanks) or an absolutehttps://address. A plainhttp://address is refused with a 422.javascript:,data:,//hostand backslash tricks are refused when you save.Layout.
stackedputs one field per row.inlineputs the fields and the button on one row, which is the usual newsletter box.
Pages, branching and prefilled answers
Pages. A page field is a page break: every field after it is on a new page, titled by the break's label, with Continue and Back buttons and a progress bar. Enter moves on. Without JavaScript the form shows every page at once, so it still works.
Branching. Any field, and any page break, can carry showIf, a test on an answer given above it:
{ "name": "team_size", "type": "number", "label": "How many people?",
"showIf": { "field": "plan", "op": "equals", "value": "Business" } }op is one of equals, notEquals, includes (a ticked option of a multiselect), answered, empty, gt and lt. Every op except answered and empty needs a value. A showIf on a page break skips the whole page. The condition must name a field above it (a later field, or a typo, is a 422 naming it), and a field cannot depend on its own answer.
The reader and the API evaluate the same rule. A question the reader never saw is never required and its answer is never stored, even if someone posts one, so a hidden question can never be the reason a send is refused.
Prefilled answers. Mark a field prefill: true and ?<name>= on the page's address fills it in: /pricing?plan=Business opens the form with the plan chosen. Hidden fields always take their value from the address, which is how you capture a campaign tag. A field's value is its starting answer. hideWhenKnown: true (HubSpot's "smart field") hides a question whose answer the address already supplied.
How a form behaves
behavior holds the settings that change how a form runs rather than what it asks. Page forms and the contact form share it (forms.behavior and contactForm.behavior), and the editor shows it as the Behaviour box.
Key | Default | What it does |
|---|---|---|
|
|
|
| off | Verify the email address with a code before sending. See below. |
| none | Refuse addresses at these domains, up to 200 ( |
| off | Keep partial answers from people who start and do not send. Kept 90 days, then swept. |
| none | Up to 10 thank-yous chosen by the response's score. See Scoring. |
| none | Up to 5 destinations every response is appended to. See Sending responses to your own tables. |
The reader is told flow, verifyEmail, savePartials and scoreEndings. It is never told the blocked domains or anything about the integrations.
Verified email. With verifyEmail on, the form asks the reader for a 6-digit code mailed to the address they typed (email template forms.verify-code, through your organization's mail provider) before it will send. The code is valid for 10 minutes and allows 5 tries. Codes are limited to 4 per address and 20 per network per hour. A confirmed code becomes a token, bound to this form and this address, that the send carries as _emailToken. It lasts an hour. A send without a valid token is refused with a 422 (_emailToken: ["unverified"]).
POST /public/orgs/{orgId}/form-flow/form/{formId}/verify-email { "email": "…" }
POST /public/orgs/{orgId}/form-flow/form/{formId}/verify-email/confirm { "email": "…", "code": "123456" } → { "token": "…" }Scoring
Give answers points and the API adds them up (Typeform's and Tally's calculations). points is set per field:
on a
select,radio,multiselect: points per option,{ "Enterprise": 10, "Startup": 3 };on a
yesno:{ "yes": 5 }; on acheckbox:{ "on": 2 };on a
number,ratingorscale: points per unit,{ "*": 2 }.
Each value is between -1000 and 1000. The score is computed by the API on the checked answers, never taken from the browser, and stored with the response (the inbox shows it). {score} in the thank-you message prints it. behavior.scoreEndings picks a thank-you by range: the first ending whose min/max (both inclusive, either may be left open) holds the score supplies its title, message and redirectUrl.
"behavior": { "scoreEndings": [
{ "min": 20, "title": "You qualify", "message": "Score {score}. Book a call below.", "redirectUrl": "/book" },
{ "max": 19, "message": "Thanks, we will be in touch." }
] }A form with no scored field has no score and says nothing about one.
Taking a payment with a form
Taking payments is on paid plans only (Starter and up), to keep fraud off the platform; the money goes through your own Stripe and we take no cut. On the free plan the editor shows an upgrade prompt in place of the switch, and the API answers 402 plan.required to a form created or updated with a payment. A paid form that existed before 20 October 2026 is grandfathered and keeps charging on any plan. See Which plans can take payments.
payment charges the reader when they send (Typeform's and Tally's payment step). In the editor it is Take a payment with each response.
Key | What it does |
|---|---|
| What the payment is for, as Stripe shows it on the checkout and receipt. Required. |
| Three letters, lower case. Default |
| A fixed price, at least 50 (0.50). |
| Or: the amount from one of the form's own answers (a donation box, a choice of tiers), read in whole units ( |
| Bounds for |
Set exactly one of amountCents and amountField. The amount always comes from the stored form and the answers the API has already checked, never from an amount the request names. An amount outside the bounds is a 422 on the field.
What happens on send:
The answers are checked as usual and filed as a response with payment status pending.
The API opens a Stripe Checkout on your organization's own Stripe account and answers with its
checkoutUrl. The reader is sent there (the reader script only follows acheckout.stripe.comaddress). A paid form must be sent from a page, because Stripe needs a page to send the reader back to.When Stripe reports the payment, the response becomes paid, and only then is it announced: the notification email, the
form.submittedwebhook, theform.paidwebhook and your integrations. An unpaid response never alerts anyone.A refund, from the console or from Stripe, marks the response refunded.
Paid responses also appear as orders in Settings → Shop → Orders, where you can refund them. The promotion-code, Stripe Tax and ship-to switches there apply to form checkouts too.
What you need to set up
A paid form needs your organization's Stripe account connected under Settings → Payments, with sales switched on and the webhook registered. That is the same connection every paid feature uses: Payments and the shop. Until it is, sending a paid form answers 409 ("This organization does not take card payments yet"). The answers are still kept as an unpaid response (and an order awaiting payment), and nobody is notified about them. Test the form before you publish it.
Insights and partial answers
Every form, page form or contact form, counts views, starts and sends per day, and shows where people stop: the drop-off per question (the last question answered before they left). The panel is on the form's page in Console → Forms, and in the contact space's Contact form settings.
With behavior.savePartials on, the answers a reader typed before leaving are kept as partials you can read and delete. They are deleted after 90 days. Without it, only the counts are kept.
GET /orgs/{orgId}/form-insights/form/{formId} forms.read
DELETE /orgs/{orgId}/form-insights/form/{formId}/partials/{partialId} forms.manageFor a contact space use contact/{spaceId} instead of form/{formId}; that needs space.read (and space.update to delete). The reader reports its progress to POST /public/orgs/{orgId}/form-flow/{kind}/{id}/progress, a counter only, throttled per network.
Sending responses to your own tables
behavior.integrations appends every response to a table you own, using your own token (Send every response to in the Behaviour box).
| Needs | Token |
|---|---|---|
|
| an Airtable personal access token |
|
| a Notion internal-integration token |
|
| a Google service-account key (the JSON); share the sheet with the service account's address |
The token is write-only: it is stored encrypted, read back only as tokenSet: true, and a save without token keeps the stored one. enabled: false pauses a destination. Delivery happens after the response is stored, so a failing destination never loses a response. The insights panel shows the last delivery to each destination, with its error. Spam is never delivered, and a paid form's response is delivered once it is paid.
What you need to set up
Each destination needs your own account and token with that service, entered in the form's Behaviour box. Nothing is sent anywhere until you add one.
When a form stops taking responses
A page form is open or closed: status: "closed" stops it, the block shows that it is not taking responses, and a send answers 409.
The contact form of a contact space can also close itself (PATCH /orgs/{orgId}/spaces/{spaceId}/contact/form, When the form closes in its settings):
Key | Default | What it does |
|---|---|---|
| none | Closed from this moment (ISO 8601 with an offset). |
| none | Closed once this many messages have arrived (1 to 1,000,000). Spam does not count, so a bot flood cannot close a sales form. |
| none | At most this many messages from one address in 24 hours (1–100). |
| the reader's own words | What the page and every contact-form block show instead of the form (500 characters, translatable). |
All three are checked by the API against the database's own clock and counts, never the reader's clock. A closed form answers 410 with your closedMessage. A sender over maxPerSender answers 429 and is told to try again tomorrow.
The conversion event
When a form sends, the page fires a forumulate:form-sent DOM event (detail: form, kind (form or contact), topic, reference) and, if your own tag manager put a dataLayer on the page, pushes { event: "forumulate_form_submit", … } to it. We load no analytics script and send nothing to a third party. A contact space also has a /thanks address for goals that count URLs (Contact spaces).
Consent
If your form collects personal data, set consent text, for example "I agree to the privacy policy and to being contacted." The form then shows a checkbox with that text and will not send until it is ticked. Each submission stores the exact text the reader agreed to and when they agreed. The console shows both, and the CSV export includes them in consent_text and consent_at.
Spam and abuse
Every submission passes these checks, in this order:
A closed form answers
409.Size. A request body over 64 KB answers
413.A honeypot. Every form has a hidden
_hpfield that people never see and bots fill in. A filled honeypot gets a success reply, and nothing is stored.A rate limit. Each network gets 10 submissions per form every 10 minutes. On top of that there is a per-address throttle on the endpoint. Going over either answers
429.Your screening list, by network. A network you have screened out (Settings → Screening) gets the same silent success as the honeypot.
Your captcha, if you configured one (Turnstile or hCaptcha, using your own keys). The page shows the widget automatically. A reader without JavaScript cannot solve it, so if you require a captcha, those readers cannot send.
Field validation against the stored definition, and the consent box if the form has consent text. A failure answers
422anddetailsnames each field that is wrong.Your screening list, by email. If the submitter's email address is screened out, the same silent success. This runs after validation because the address is one of the answers. Then the form's own rules: an address at one of its
blockedDomainsis a422, and withverifyEmailon, a send without a valid code token is a422(How a form behaves).Content heuristics: a flood of links, HTML or BBCode links, or a long run of text with no spaces. These do not reject the submission, because a false positive would lose a real lead without anyone knowing. The submission is stored and flagged as spam, no email is sent, and no webhook fires. You can review it under Flagged as spam and mark it "Not spam".
A submission is limited to 64 KB, 5,000 characters per answer and 40,000 characters in total.
Notifications
Email. Each response that is not spam is emailed to the form's notify addresses, or to your organization's support address if the form has none. Mail goes through your organization's own mail provider, the same one the contact page uses.
Webhook. Subscribe an endpoint to
form.submitted(Webhooks). The event'sdatacarries exactlyformId,titleandformName(both the form's name),submissionId, the answers indata, the submitter'semail,pageUrlandcreatedAt. It is not sent for spam.
Without JavaScript, and with it
The block renders a real HTML form that posts to your own address: /api/public/orgs/{orgId}/forms/{formId}/submissions on our domain, and /<mount>/_api/public/… on a custom domain. The browser's own validation (required, type="email", pattern, min) runs before anything is sent.
Without JavaScript, the API answers a plain form post with a
303back to the page you came from, plus a fragment:#fm-{formId}-sentor#fm-{formId}-error. The page shows the matching message using CSS alone. If the form has an external redirect, the API returns a short page that moves the reader on, because browsers apply the page'sform-actionpolicy to redirects too.With JavaScript, the reader posts JSON instead. It puts each error next to the field it belongs to, marks that field
aria-invalidand moves focus to it. On success it shows the thank-you message in place or follows the redirect.
The reader's Content-Security-Policy now carries form-action 'self' (plus our own origin). Every form the renderer emits posts to the same origin it was served from, so this holds on every address.
The API
Public. No credentials are needed:
GET /public/orgs/{orgId}/forms/{formId} the public definition (no notify addresses)
POST /public/orgs/{orgId}/forms/{formId}/submissions send a formA submission is a flat object with each answer under its field's name, plus these reserved keys:
Key | Meaning |
|---|---|
| the honeypot; leave it empty |
|
|
| your captcha's token, when you configured one |
| no-JS only: the page to return to (same origin as the post) |
| the browser's id for this pass, so insights count the send as that pass's end |
| the verified-email token, when the form verifies addresses |
POST /public/orgs/{orgId}/forms/{formId}/submissions
{ "email": "ann@example.com", "role": "PM", "_consent": true }
201 { "ok": true, "submission": { "id": "…", "createdAt": "…" },
"successMessage": "Thanks — you are on the list.", "redirectUrl": null }A scored form adds score; a paid form adds checkoutUrl, the Stripe page to send the reader to.
Owner side. These need forms.read or forms.manage:
GET /orgs/{orgId}/forms forms.read list, with response counts
POST /orgs/{orgId}/forms forms.manage create
GET /orgs/{orgId}/forms/{formId} forms.read
PATCH /orgs/{orgId}/forms/{formId} forms.manage any subset of the fields
DELETE /orgs/{orgId}/forms/{formId} forms.manage also deletes its submissions
GET /orgs/{orgId}/forms/{formId}/submissions?spam= forms.read paged once you send a version
GET /orgs/{orgId}/forms/{formId}/submissions.csv forms.read CSV export
PATCH /orgs/{orgId}/forms/{formId}/submissions/{submissionId} forms.manage { "spam": true|false }
DELETE /orgs/{orgId}/forms/{formId}/submissions/{submissionId} forms.manage
GET /orgs/{orgId}/form-submissions?email= forms.read a person's data, across all forms
DELETE /orgs/{orgId}/form-submissions?email= forms.manage erase itThe CSV has one column per current field, then any older field that still has answers, then submitted_at, spam, consent_text, consent_at, page_url and id. A cell that starts with =, +, - or @ is prefixed with an apostrophe, so a spreadsheet cannot run a stranger's text as a formula.
Who can do what
forms.read lets someone see forms and the submissions they collected. Submissions are personal data a stranger handed over, so this is its own capability rather than part of space.read. forms.manage lets someone create, edit and delete forms and submissions. Owner and Admin hold both. So do Maintainer and Editor, because they place forms on the docs and blog pages they own. Member and Guest hold neither. For tokens, the scopes are forms:read and forms:manage (API tokens and scopes).
Data-protection requests
Submissions are tenant data and are deleted with your organization. For a single person:
Access:
GET /orgs/{orgId}/form-submissions?email=person@example.comreturns every submission, across every form, whose first email answer is that address.Erasure:
DELETEon the same address deletes them all and returns the count. The console's form page has a "Data requests" box that does the same thing.One submission:
DELETE …/submissions/{submissionId}.
Matching uses the first email field of each form. A form with no email field cannot be searched this way, so delete those submissions one at a time.
In markdown
The block is exported as a single encoded directive, because field labels are data, not prose. It survives a round trip through git sync and the .md export:
:::form{id="6f1c2a5e-0b8e-4a5b-9f60-3a7b2c1d4e5f" data="%7B%22fields%22…"}:::A contact space's form on any page
The Contact form block (slash menu: Contact form) places a CONTACT space's form on any page — "Talk to sales" under a pricing table, "Still stuck?" at the foot of a guide. It names the space and nothing else: the questions, the topics, the button and the thank-you are read live from that space when the page renders, so editing the contact form in its Settings fold updates every page carrying the block. Optionally fix the topic (the message is routed as that topic and only that topic's questions are asked) and override the heading, description and button label.
Messages land in the contact space's inbox exactly as if sent from the contact page: topic routing and assignment, auto-replies, staff mail and the contact.submitted webhook all apply. Like a form block it works with no JavaScript (it posts to POST /public/orgs/{orgId}/spaces/{spaceId}/contact/submissions and is answered with a 303 back to the page), and with scripts on it shows only the questions the chosen topic asks. In markdown it is :::contactform{space="<spaceId>" data="…"}:::.
The block carries the same booking step, work-email hint, closed state and theme.contact.form look as the contact page, its built-in words follow the page's language, and your translations reach it. Upload questions (file, signature, video) cannot be answered from a block: it shows a note linking to the contact page. The API does not require a file question from a block send, but a required signature or video question is still required, so keep those optional on a contact form you place as a block.
A form block placed in a contact space's own intro is live too.
A contact inbox: folders, spam, CRM and retention
A contact space's messages are filed in folders — Inbox (unread and read), Archived and Spam — set with PATCH /orgs/{orgId}/spaces/{spaceId}/contact/{id}/status (unread read archived spam) and listed with ?folder=inbox|unread|archived|spam|all. Without folder the list is everything except Spam.
Each message is scored on arrival: too many links, pasted markup, keyword stuffing, known spam phrases, a link where a name goes, and the organization's screening list (a screened address, domain or link puts it straight over the line). A message over the line is kept in Spam — the sender gets the same answer as anyone — but nobody is alerted, no auto-reply goes out and no webhook or CRM lead fires. "Not spam" moves it back. Turn scoring off with contactForm.spamFilter: false. contactForm.workEmailOnly: true refuses consumer mailboxes (gmail.com, outlook.com…) with a 422 on email, which the form shows beside the address.
CRM handoff. Install the HubSpot integration (a HubSpot form's submit URL) or the Salesforce one (your Web-to-Lead URL with oid) and every contact.submitted becomes a HubSpot form submission or a Salesforce lead: the name split into first and last, the message (with its topic and subject) as the message/description, and each question under its own name — name a question company, phone or website and it fills that CRM property.
Booking. contactForm.bookingUrl (or a topic's booking) is a Cal.com or Calendly page framed under the thank-you, with the sender's name and address filled in.
Retention and data requests. contactForm.retentionDays deletes messages older than that many days, nightly. For one person, GET /orgs/{orgId}/contact-submissions?email=… returns everything that address sent the organization across every contact space, and DELETE on the same address erases it (replies included) — both need org.manage_settings. The inbox's "Erase sender's data" does the same.
Languages. contactForm.translations holds the form's words per reader locale (fr, pt-BR): topic names and lines, question labels, help and placeholders, the button and the thank-you. Readers in that language see them; what is posted and stored stays the original, so routing and the inbox keep one vocabulary.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Contact Block
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Create a submission in a space (contact) |
|
Contact Privacy
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List contact submissions in an organization |
| |
| Delete contact submissions in an organization |
|
Forms
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List forms in an organization |
| |
| Create a form in an organization |
| |
| Get the form |
| |
| Update the form |
| |
| Delete the form |
| |
| List submissions in an organization |
| |
| File of submissions |
| |
| Update the submission (forms) |
| |
| Delete the submission |
|
Form Submissions By Email
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List form submissions in an organization |
| |
| Delete form submissions in an organization |
|
Public Form Flow
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Create the progress in an organization |
| |
| Verify email in an organization |
| |
| Confirm the verify email |
|
Public Forms
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the form (public reader) |
| |
| Create a submission in an organization |
|