# formbase Docs (Complete) --- ## Getting Started # Introduction What formbase is, what it does, and who it is for. ## Introduction formbase collects and verifies information from your customers. Send one person a prefilled form, get structured answers back, and let your workflow or AI agent carry on.
Most workflows run fine until they need something only a customer can give: billing details, a signed contract, a missing invoice line, a yes or no. The run stops, and somebody writes an email.
formbase is that step. You build a form once, then send it to one named person as a request. They open a branded form in their own language with what you already know filled in. When they finish, formbase hands the answers back to your workflow.
> Every published form also has a public link for anyone to fill in — as a link, a QR code, an embed, or a popup. Answers > from both channels land in the same inbox. Share a public link → >
formbase is free to start: unlimited forms, workspaces, and members, and 1,000 submissions and requests a month. Pro and Business raise the limits and add request emails (Free gets 10 invitation emails to try them), custom domains, advanced analytics, AI Skills, and more.{' '} Compare plans →
New here? Follow this section in order: sign up, build a form, send your first request. Or jump straight to what you came for.
Open app.formbase.so. There are two ways in, and neither needs a password:
Signing in for the first time creates your account. No credit card, no trial clock.
A workspace holds your forms, submissions, integrations, and teammates. The first screen after signing in says{' '} Welcome to formbase and has one button: Create Workspace. New to the workspace/account/form distinction? See{' '} how they fit together.
All it asks for is a name — your company, team, or just Personal. Your timezone comes from your browser. Change either later in{' '} Workspace Settings, under Workspace details.
Need more than one? Every plan includes unlimited workspaces — useful for separating clients, brands, or projects. See{' '} multiple workspaces.
Working solo? Skip ahead. For teams, open the workspace switcher at the top of the sidebar → Workspace Settings, then click Invite in the Members section. You get a link that anyone can use to join. Members and workspaces are unlimited on every plan, and any member can invite — renaming, removing members, and deleting the workspace are the owner's. See{' '} inviting members.
You have an account and a workspace. Next: build a form and publish it — everything else in formbase starts from a published form.
The editor works like a document. Type where you want text, and press a key to add a question.
Forms in the sidebar and click Form, top-left. Or go to app.formbase.so/try to build one without an account.', }, { title: 'Add questions', description: 'Click in the editor and press / to open the block menu. Pick a question type, type the label, and hit Enter. Drag the handle on the left to reorder.', }, { title: 'Preview', description: 'Click Preview in the toolbar to walk through the form exactly as the person answering it will.', }, { title: 'Publish', description: 'Click Publish (top-right). The form goes live, its field keys freeze, and a Share button appears in the toolbar — it is only there once a form is published.', }, ]} /> > ℹ️ **Safe to experiment** >Edits autosave as a draft. Your live form keeps the last published version until you publish again.
Publishing is not only "make it visible". It fixes three things you will rely on later:
company_name that automations address it by,
and that name stays the same through every later edit. Field keys →
> Templates — open Templates in the sidebar for 19 ready-made forms in six categories.{' '} > AI — open AI chat (bottom-right) and describe what you need. See Using AI. >
> You deliver the link yourself here, so nothing needs upgrading. Letting formbase email the invitation and chase people with reminders > needs a Pro or Business plan. On Free, formbase can email your first{' '} > 10 invitations to try it. >
Open the test link in a new tab and answer the form as your recipient would. It is the form you authored — your theme, your logo, your language — with no captcha and no sign-in. When you submit, you get your thank-you page.
Open the same link again afterwards. Instead of a blank form you get the outcome page — your thank-you page again, or{' '} You have already submitted, under the same cover and logo. A request is answered once, by one person, and the link says so rather than quietly collecting a second response. Expired and canceled requests show a short notice there instead.
Click Requests in the sidebar, between Forms and Trash. This is every request in the workspace, across every form. Open{' '} Filter and tick Show test requests to see the one you just made — test requests are hidden by default and carry a Test badge.
Click a request to open its drawer. It answers two questions:
From here you can copy the request link, cancel the request, send a reminder (Pro), or replay a callback. A request expires 30 days after it is created unless you set your own Expires in (days), between 1 and 365.{' '} The Requests page →
Doing it by hand is the whole loop in miniature. When a workflow creates the request instead, three things join in — and they are the reason requests exist:
> The curl and MCP tabs on that same card are not a different feature — they run exactly what the Manual > tab runs. Copy one, swap the recipient, and you have automated the step. Creating a request → >
Each share link carries its own settings, grouped into Link, Query parameters, Appearance, and Limits:
A link with responses is revoked rather than deleted: it stops accepting new ones but keeps its analytics, and you can re-activate it later.
> ℹ️ **Which channel should this be?** >> Use a share link when anyone may answer and you do not know who in advance. Use a{' '} > request when one named person owes you one answer and your workflow needs to know > when it arrives. Both spend from the same monthly allowance, and both land in the same inbox. >
Open the form and click Submissions in the top toolbar. It opens on the response list; every answer is here, whoever it came from, and the Channel column says which channel each one arrived through:
The test request you answered is not here: a test submission is stored on the request, but kept out of the inbox, the counts, exports, and your integrations. The public link you answered is.
The same toolbar button has three views:
See submission inbox for the full walkthrough.
> ℹ️ **A request also has a page of its own** >> The inbox holds the answers. The Requests page holds everything around them: who has not > opened yet, what you prefilled, which reminders went out, and whether the callback was accepted. The answers are in the inbox; the state > of the request is on the Requests page. >
Open Settings → E-mail Notifications and turn on Notify on new submission. Your own address is filled in for you. This works on Free, no upgrade needed. On Pro, you can add more recipients and write your own subject and body. See{' '} self-notifications for details.
Want the person who answered to get a confirmation too? Turn on Send confirmation to respondent (Pro) from the same panel. On a request, it goes to the recipient you named. See{' '} respondent notifications.
Notifications tell a person. These two tell a system — and which one you want depends on the channel:
Three regions:
Two shortcuts work anywhere: / opens search, and{' '} / opens and closes AI chat.
Top of the sidebar. Click to switch workspaces, create a new one, open Workspace Settings, or leave a workspace you don't own. Workspaces overview →
One line each — follow the link for full docs.
Below that:
Just above your avatar sits one card with four rows:
Click your avatar at the very bottom of the sidebar. The menu has two items:
The button in the bottom-right opens AI chat — an assistant that can create, edit, and analyze your forms. It is on every page, and{' '} / opens and closes it. For what it can do, see{' '} AI chat in the editor guide and AI overview.
> ℹ️ **Connect your own agent** >> Prefer Claude, ChatGPT, Cursor, or another tool? See Connect an AI agent. >
Yes. The Free plan includes unlimited forms, 1,000 submissions and requests a month from one shared allowance, 100 MB of storage, unlimited workspaces and members, conditional logic, file uploads, integrations, translations, CSV and Excel exports, and email notifications to your own address. No credit card to start. AI works on Free too — you buy a credit pack, the same as on every plan.
Need more room? Pro and Business raise the allowance to 50,000 a month, storage to 500 GB, and add request invitations and reminders, custom domains, custom themes, advanced analytics, and AI Skills. Business adds version history. Pro starts with a 14-day free trial on monthly billing. See plans & pricing for the full comparison.
No. Anyone with the share link (or a published embed) can submit with no account. You can optionally gate a form behind a password, or require respondents to log in with a formbase account (via Google or email magic link) before they can submit. See{' '} authentication gate for details.
A share link is public: anyone who has it can submit, any number of times. A request is one assignment of the same form to one named person — with values you already know prefilled (and optionally locked), one completion, its own expiry, its own reminders, and a signed callback to your workflow the moment they finish.
Both are channels on the same form, and a form can use both at once. Create a request by hand from{' '} Share → Requests → Manual, or from Make, Zapier, Claude via MCP, or the API. Handing out the link yourself works on every plan; having formbase email the invitation and chase it needs Pro or Business, though a Free account can have formbase email its first 10 invitations to try it. See the requests overview.
Into one inbox per form, whichever channel they came through. The Submissions button in the form toolbar has three views:{' '} Summary (charts per question), Submissions (the response list), and Analytics (traffic, conversion, and device breakdown — Pro). You can also get an email on every response (more addresses on Pro), or route submissions to Google Sheets, Notion, Airtable, Slack, Discord, Linear, GitHub, or a signed webhook. See{' '} see answers arrive for the walkthrough.
Yes. Click your avatar at the bottom of the sidebar → Settings, then scroll to Delete Account in the Danger Zone. Type your email address to confirm. This permanently removes your forms, submissions, files, and the workspaces you own. There is no undo.
If you have an active Pro or Business subscription, deleting your account also cancels it — formbase tells Polar to stop billing as part of the deletion, so you won't be charged again.
Yes. AI is optional throughout — ignore the AI button and build every form by hand with the slash menu. Credits are only spent when you ask AI to do something. When they run out, the editor keeps working; only AI chat stops.
Yes. The editor, dashboard, and all management features work on mobile browsers. Respondent-facing forms are fully responsive.
You can duplicate a form within the same workspace and share access by inviting that person to the workspace. There is no self-serve way to move a form to a different workspace or transfer workspace ownership — contact support for either.
Yes. Open Trash in the sidebar and click Restore. You pick the folder it comes back to. Deleted forms stay in Trash for 30 days before formbase removes them for good.
On the Free plan, yes — eventually. A free-plan form that goes more than a year without any edits or submissions is automatically moved to Trash — and only after formbase emailed you a warning at least 30 days earlier. You then have 30 days to restore it before it's permanently deleted along with all its submissions and files. Pro and Business forms are never auto-archived for inactivity. See{' '} Limits & quotas for full details.
Yes. When authentication is required, respondents must log in with a formbase account — via Google or an email magic link. See{' '} authentication gate. If you only need to confirm an email answer, Business plans offer respondent email verification — a one-time code on the email question itself, no account needed.
formbase delivers submissions to Google Sheets, Notion, Airtable, Slack, Discord, Linear, GitHub, and signed webhooks, and connects to Cal.com for Schedule appointment blocks. Every plan can stream form analytics to Google Analytics and Axiom. There is no per-plan cap on how many integrations a form has. Zapier and Make are listed as Coming soon on the Integrations page — but both already run the whole request loop through their HTTP steps today. See the Integrations section.
A form is a reusable design. A request is one assignment of that design to one named person — with their data already filled in, their case number attached, and a callback waiting for the moment they finish.
You do not build a different form for this. Any published form can be sent as a request. Nothing about the form changes; what changes is who opens it and what your automation knows when they are done.
In the Share sheet, a published form has two tabs — Public link and Requests. These are the two{' '} channels a form reaches people through, and a form can use both at once. The public link goes on your website; requests go to the three suppliers you are chasing this week. Same form, same submission inbox.
> ℹ️ **Three ways to create a request** >> Open a published form, press Share, and pick the Requests channel. The Manual tab > creates one request by hand — fill in the recipient, prefill what you know, and press Send invitation or{' '} > Create link. The curl and MCP tabs hand you a ready-made snippet for the same thing > from Make, Zapier, Claude, or a direct API call. >
>> A request made by hand behaves exactly like one an automation created: same lifecycle, same reminders, same signed callback.{' '} > Try it yourself creates a test request addressed to you and hands you its link — nothing is emailed — so you can walk > the whole flow through before you wire anything up. >
> ℹ️ **Step by step in Zapier and n8n** >> The Zapier guides walk through it click by click:{' '} > send a request from a Zap, act on its outcome, > and look it up, remind or cancel it. The same three jobs have{' '} > guides for self-hosted n8n. >
requests.create with a form id, the recipient, and any values you already know.
https://form.formbase.so/r/rq_…. It opens as many
times as the recipient needs, from any device.
If they never finish, reminders chase them on a schedule, and the request eventually expires. Either way your automation is told — the callback fires on expiry and cancellation too, so a workflow run is never left hanging.
Completed, expired, and canceled are terminal: the link keeps working but shows an outcome page — a receipt, or a plain "this request has expired" notice — instead of the form.
While a request is pending, formbase also records when it was first opened, when the first answer was saved, and the last activity. That is what the Requests page means by "not opened", "opened", and "in progress" — activity, not extra statuses.
Open your published form, click Share, and pick the Requests tab. The card there gives you everything you need to make the first call:
> An unpublished form cannot be requested, and the snippets stay disabled until you publish. Field keys are frozen at first publish — that
> is what lets your automation keep addressing company_name a year later. See Field keys.
>
fields.list returns every field of the form's current published version, with the key to address it by, the value shape it
takes, and which bucket it belongs in.
context: true marks a hidden field. Its value goes in context, never in prefill; a hidden field's
key in prefill is rejected with UNKNOWN_FIELD_KEY.
calculated: true marks a calculated field. The form works out its value, so nothing can send one; you read it back under
its key in answers.
prefillable: false marks a field nobody can supply a value for: file upload, signature, payment, appointment booking, and
Documents blocks. The recipient fills in the questions themselves. Hidden fields and calculated fields also show{' '}
prefillable: false: hidden fields take context, and calculated fields take nothing.
options lists the choices for a choice question. Send the option's key, not its label; the label is there
so you can match the choice you know to its key. A matrix lists its rows and columns the same way.
type: "group", repeating: true, and a list of{' '}
members.
deliveryStatus is "queued" when formbase emails the invitation and "not_requested" when you deliver
the link yourself.
Three different things can be attached to a request, and mixing them up is the most common first mistake.
Initial answers for the visible questions, so the recipient reviews and corrects instead of typing from scratch. Everything you already know about them belongs here — the company name from your CRM, the amount from the invoice, last year's answers.
List a prefilled key in readonly and the recipient sees the value but cannot change it. Use it for the facts they are
confirming rather than supplying — the contract number, the agreed price. Locking is per request: the form itself is untouched, and the
same field is freely editable on the next request.
Every locked key must also be prefilled, and a locked required field must be prefilled with something non-empty — otherwise the recipient would face a form they could never submit, and formbase rejects the call instead of creating that trap.
Trusted values for the form's hidden fields — a case number, a workflow run id, an amount. Context feeds variables, conditional logic, calculated fields, and email copy, comes back unchanged in the callback, and the recipient cannot alter it. That last part is the difference from seeding a hidden field through a URL on a public link, where anyone can edit the query string; request links ignore URL query parameters entirely. Context values must be a string, number, or boolean.
Context is not free-form: every key must be a hidden field on the form's published version, and any other key is rejected with{' '}
UNKNOWN_FIELD_KEY. Bookkeeping that has no hidden field, like an execution id, belongs in metadata.
Hidden fields are not shown on the form, but a context value is not secret from the recipient. They see it wherever the form or the
invitation shows it: a mention in the form content or email copy, or a visible question that
uses that hidden field as its default value. In that last case the
recipient sees the context value pre-filled in that question and can edit the answer. The context value itself stays unchanged. A{' '}
prefill for that question's own key wins over the default.
Your own bookkeeping — an execution id, a CRM record id. It never reaches the form at all, so it cannot be piped into copy or read by logic; it just rides along and comes back in every callback and status read.
Send values in the shape the type from fields.list asks for. A wrong shape comes back as a validation error
naming the key, the expected type, and — for choice questions — the values that would have been accepted.
A Documents block hands files to the respondent. Its authored files are the same for everyone and always stay; a request adds files for its one recipient below them — the customer's own lease contract, an ID copy to check. Bytes never travel through the API call itself: upload first, then reference.
``` "documents": [ { "documentId": "kn7...", "name": "Your lease contract" }, { "documentId": "kn8..." } ] ```
name overrides the display name stored on the upload. When the form has more than one Documents block, name the target with{' '}
field, the block's field key (fields.list lists it, together with the authored documents every respondent
already gets). The files land below those authored documents — a request adds files, it never replaces one. One upload can be referenced
by as many requests as you like — a price list uploaded once serves five hundred requests.
Limits: PDF and images only, 25 MB per document, 100 MB per request (DOCUMENTS_TOO_LARGE), and at most 20 documents per block
counting the authored ones (DOCUMENTS_TOO_MANY). The files count against your workspace storage and are freed once the
requests referencing them fall out of the form's retention window. The submission records the list the recipient saw under the block's
field key, so the callback tells you exactly which files this person was given.
Pass test: true to exercise the whole shape before a real run. A test request is real in every way that matters for wiring:
the link opens and can be completed, the callback fires as usual, and requests.get returns
the answers. What it never does is reach anyone or anything you would have to clean up afterwards:
delivery says. Send reminder is refused on it, and it
spends none of your monthly allowance.
"test": true, so your workflow can branch or ignore it.
requests.list unless you pass includeTest: true.
expiresAt asks for longer; the expiresAt in the response says when.
On Free, a workspace may create 10 test requests a day. The next one fails with RATE_LIMITED and reason{' '}
TEST_REQUEST_LIMIT_REACHED, and retryAfterMs says when you can try again. Pro and Business have no daily cap.
Try it yourself on the Share sheet is this mode with a single click: it takes the Manual tab's draft — prefill, locks, context, callback, expiry — addresses the request to your own account, sends no email, and hands you the link to open yourself.
Every plan has one monthly allowance shared by both channels: a share-link submission spends one unit, and so does every
request you create — whether the recipient answers, ignores it, or you cancel it. The submission a request collects is already paid for
and counts nowhere. Free includes 1,000 units a month, Pro and Business 50,000; the count resets on the 1st of each month, UTC. At the
cap, requests.create fails with UPGRADE_REQUIRED and reason MONTHLY_ALLOWANCE_REACHED; requests you
already created stay answerable.
On Free, a request created with "delivery": "email" also spends one of the account's{' '}
10 free invitations. They never reset; once they are gone, email
delivery fails with UPGRADE_REQUIRED and reason FREE_INVITATIONS_USED.
Pass the same idempotencyKey with the same body and you get the original request back, with deduplicated: true{' '}
and the original link — no second request, no second email. Reuse the key with a different body and formbase refuses with{' '}
IDEMPOTENCY_CONFLICT rather than guessing which one you meant. Keys are scoped to the workspace and honoured for 30 days;
after that the same key starts a new request.
In a workflow tool, the execution id is the natural key: a run that is retried after a network blip picks up the request it already created.
requests.create and documents.create share a budget of 60 calls a minute, counted per API token
(or per user, for a call made without one). A backlog you are draining should pace itself; a burst over the budget is refused and can be
retried.
If the form is already published on one of your custom domains, request links are minted
there automatically — https://forms.yourcompany.com/r/rq_…. Name domainId explicitly when the form is published
on more than one. The domain must belong to the same workspace as the form.
Exactly the form you authored — same theme, same logo, same language — with their values in place, locked fields read-only, and no captcha to solve. When they submit, they get your thank-you page. If they come back to the link afterwards, they get the outcome page instead of a blank form.
There is no message from your automation on the page. Anything the recipient needs to be told belongs in the form itself, where you can personalise it by mentioning a context value or a prefilled field.
An AI agent runs the same two steps as fields_list and request_create, with the same options — documents and{' '}
domainId included. See Requests on the MCP server.
Without field keys, an automation has to address your questions by internal ids that mean nothing to anyone — and a webhook payload arrives full of them. With field keys, both sides read the same way:
``` { "company_name": "Acme", "employees": 120, "contacts": [{ "name": "Ada" }] } ```
Field keys are used in two places: prefill, context, and readonly when creating a request; and the{' '}
answers and display maps in every callback and every webhook payload.
Every key the form publishes lives in one place, the Keys table: each field, and under a choice question each option,
under a matrix each row and column. It ends with a preview of the answers object your callback will carry, with your keys in
it.
Each option's hint row — the grey line under the option you are editing — ends with its key as a chip. Click the chip to edit the key in place, press Enter or Escape when done. A matrix shows the chip for the row or column whose label you are editing. A chip turns amber when the form is published and the key you typed would move the one automations already use.
The same table appears read-only where you wire an integration: under the webhook's field mapping, and under the request snippets in the
Share sheet's Requests card. You can also read every key at once with fields.list.
You do not have to set anything. Until you edit it, a key is derived from the question's title: accents are folded and letters such as ø, æ and ß become o, ae and ss, everything is lowercased, each run of anything that is not a letter or digit becomes a single underscore, leading and trailing underscores are dropped, and the result is cut to 64 characters.
A title written only in a non-Latin script, such as Arabic, Hebrew, Cyrillic, Greek, Chinese or Japanese, has nothing to fold, so its key
is field_ plus its position, and an option's is option_ plus its position. These keys are as stable as any other
once published, but they say nothing about the question. When an automation reads the answers, set a readable key yourself in the Keys
table.
If two fields would end up with the same key, formbase breaks the tie in document order by appending _2, _3, and
so on. A key you set yourself always wins its claim, and the derived one yields.
Type a name in the Field key input to override the derived one. Clearing the input goes back to the derived key. Allowed characters are
letters, digits, _, . and -, up to 64 characters — anything else is refused with{' '}
"Use only letters, numbers and _ . - (max 64 characters)." Spaces are turned into underscores as you type, so "contact name"
becomes contact_name.
Keys are case-sensitive and must be unique within one form. Reusing one already taken by another question, repeating group, hidden field, or calculated field is refused with "Another field already uses this key."
> On a published form, the Field key input warns you that the form is published and automations using the current key will break. Nothing > stops you, but every workflow that prefills or reads that key stops matching the moment you publish the change. Update the automation in > the same sitting. Publish warns you again before the change goes live. >
The first time you publish, every field's key is written into that published version. Every later publish carries the same keys forward, which means:
company_name.
Your automations keep working; only the words on the page change.
_2; derived keys are made unique at publish.
Publishing is also where key collisions are caught, and both of them stop the publish rather than silently renaming a field:
Both show up beside the Publish button with every other pre-flight finding — see{' '} Publish checks.
A key belongs to the field it was published on, not to its title. So there are two ways to lose one without meaning to: type a different key on a published field, or delete a question and add a new one in its place. The new question is a new field — it gets a fresh key derived from its own title, and the old key is gone.
Nothing fails on formbase's side when that happens. The webhook still fires and the callback still arrives, only without that answer, and{' '}
requests.create starts refusing the old key with UNKNOWN_FIELD_KEY. So publish checks for it first. When a key
the live version publishes would not exist in the next one, the publish dialog and the issue indicator beside the Publish button show a
warning:
form_publish returns the same messages in warnings.
A repeating group has its own key, and so does each field inside it. Automations address the group as a whole and nest the members:
``` { "contacts": [{ "name": "Ada", "email": "ada@acme.com" }, { "name": "Grace", "email": "grace@acme.com" }] } ```
A field inside a group is only reachable through its group — there is no top-level name here, only{' '}
contacts[0].name. Rename the group's key and the whole array moves; rename one member's key and only that name inside each
object changes.
A hidden field's parameter name is its field key. That is the key you put in{' '}
context when creating a request, and the same name you would use in a URL parameter on a public link.
A calculated field's name is its field key, and it shares the form's one set of keys with
everything else. It is read-only: the form works out its value, so you can never send one — fields.list lists it with{' '}
calculated: true and requests.create refuses it in prefill and context. You do read it
back: it arrives in answers under its name (answers.total). Renaming a published calculated field renames its
key, with the same publish warning as any other field.
The parts of a question that an answer names get keys too. Every option of a radio, select, checkbox, picture choice or ranking question, and every row and column of a matrix, has an option key: derived from its label the same way a field key is derived from a title, editable, and frozen at publish. So an answer reads as a name on both sides:
``` { "plan": "pro", "interests": ["billing", "api"], "satisfaction": { "delivery_speed": "very_good" } } ```
A workflow branches on answers.plan == "pro" whatever language the respondent answered in, and a request prefills a choice
with {'{ "plan": "pro" }'}. The keys are listed by fields.list under each field's options, or a
matrix's rows and columns, and edited in the Keys table or on the option's chip. Each
question's options are their own namespace, so two questions may both have a yes, and a matrix row and column may share a
key. Two options of one question that would derive the same key are told apart with _2, like fields.
The decision question is an ordinary radio whose three options carry the keys{' '}
approve, decline and changes; that is what makes a request's outcome a closed set.
Every choice already has an option key, so a workflow can branch on any radio. What a
workflow cannot do is know, across every form that has an approval, which key means "yes": one author's radio says sign_off,
another's approved.
The decision question fixes the names. It is a single-choice question with the field key decision, whose
three options carry the option keys approve, decline and changes. Its labels stay yours to reword
and translate; the keys underneath never move. When the recipient picks one, the request ends with an outcome. Any radio
keyed decision whose options carry those three keys counts — the preset just sets them for you.
> The outcome is read from the question whose field key is decision, and only when the chosen option's key is one of the
> three. A radio you build by hand with options labelled Approve, Decline and Changes derives exactly those keys, so it works without the
> preset; a radio with other labels works once you type the three keys in the Keys table.
>
These three values are a public contract, like a field key: an automation, an agent, and a spreadsheet formula all read them.{' '} Add a fourth option and picking it is an ordinary answer, not a verdict — that request completes with no outcome, rather than with a value your receiver has never heard of.
Branch on data.request.outcome. status says whether the request finished; outcome says what
the recipient decided. An expired or canceled request has a status and no outcome, and so does a completed request on a form without a
decision question.
The MCP tool is editor_insertDecisionQuestion, taking a title and the three labels in the form's language. An agent that
cannot host a callback endpoint polls request_get instead and reads outcome when the status leaves{' '}
pending. Requests on the MCP server →
> Every request comes back with a url. You can send it yourself — in your own email, a Slack message, an SMS — or ask
> formbase to email it. Sending it yourself works on every plan. Letting formbase send invitations and reminders requires a{' '}
> Pro or Business plan; a Free account can have formbase email its first 10 invitations to try it (see{' '}
> free invitations).
>
Create a request with delivery: "email" and a recipient email address, and formbase sends the{' '}
request invitation: a short email carrying a button that opens the request link.
You author it once per form, not once per request. In the editor, open Settings → E-mail Notifications and scroll to the{' '} Requests group at the bottom:
There is no free-text message from the caller — an automation cannot inject a paragraph into this email. What it can do is supply values: mention a context field or a prefilled field in the subject or body and each recipient gets their own version. That keeps the copy under your control while still reading as if it were written for them.
> 💡 **Translate it** >
> If the form has published languages, the invitation follows the request's language. Translate the email copy alongside the
> rest of the form — see Translating form content.
>
requests.remind carries the same link and counts against the eight-reminder cap. On
Free, where reminders aren't available, copy the request's link and send it yourself.
A reminder schedule is an ordered list of idle offsets — 1 day, 3 days, 1 week — after which formbase emails someone who has gone quiet. One schedule serves two kinds of target: a public-link respondent sitting on an unfinished draft, and a request recipient who has not completed.
It lives in Settings → E-mail Notifications → Send reminders. That switch is the master: turn it off and neither channel gets the form's schedule.
An automation can pass its own reminders when creating a request — ["2d","5d"] for a schedule of its own, or an
empty list to switch reminders off for that one request. An explicit schedule wins even over the form's master switch, because a caller
that passed one asked for reminders on this request specifically.
Leave reminders out and the request inherits the form's schedule, if the master switch is on and the request has a recipient
address.
You do not have to wait for the next step. Send reminder in the request drawer — or requests.remind from an
automation — queues one immediately. The automatic schedule is untouched: the next scheduled reminder still lands when it was going to.
Two floors apply: at least ten minutes between manual reminders, and the same overall ceiling of eight per request.
Every request has a hard end. When it passes, the link stops working, the status becomes expired, and your automation is told through the callback — so a workflow that has been parked for a month resumes instead of hanging forever.
expiresAt as a millisecond timestamp to set your own.
expiresAt within a quarter of an
hour. The link itself stops opening the moment the expiry passes.
All three arrive at the same URL, so branch on type before assuming there are answers. That is the whole point of firing on
every ending: a workflow parked on a customer resumes whether they answered, ignored you, or were called off.
data.request is always there — including your externalId, metadata, and context,
unchanged. It carries the timestamp of whichever ending happened (completedAt, expiredAt, or{' '}
canceledAt with an optional cancelReason).
outcome is present only when the recipient answered a decision question —{' '}
approve, decline, or changes.
form, submission, answers and display appear on completion only, in exactly the
shape a submission webhook carries. form.snapshotId is the exact
published version the recipient answered; submission.pdfUrl is a URL only when the form retains a submission PDF, and null
otherwise.
test is true when the request was created in test mode —
branch on it, or drop the event.
answers is keyed by field key, with repeating groups nested as one object per instance.
A choice answer is the option key from fields.list, not its label; the label is in display,
under the same key.
Content-Type: application/json with User-Agent: formbase, and carries{' '}
X-formbase-Event-Id, X-formbase-Event-Type and X-formbase-Signature — so you can dedupe and route
before parsing.
> id is stable across every retry and every replay of the same event. If your receiver might act twice on the same id — a
> duplicate invoice, a duplicate ticket — remember the ids you have already handled.
>
Every callback carries a signature header, X-formbase-Signature: t={unix seconds},sha256={hex}. The hex
is an HMAC-SHA256 of the timestamp, a dot, and the raw request body, computed with your workspace's{' '}
request signing secret.
Two rules, whatever language you use:
crypto.timingSafeEqual, hmac.compare_digest — never with ==.
One secret per workspace signs every callback from it. Find it on OAuth and API Keys in the workspace sidebar, in the{' '}
Request signing secret card. It is masked by default; Reveal secret shows it and the copy button copies
it. It is not a one-time value — you can come back and read it again. The secret is minted the first time it is needed, so a workspace
that has never opened that card and never created a request with a callbackUrl has none yet.
> Only the workspace owner can regenerate the secret, and the moment they do, the old one stops working — including for callbacks that are > already being retried. Update your receiver first, then regenerate. There is no window where both secrets are accepted. >
A callback gets eight attempts: the first, then seven retries at least 1, 2, 4, 8, 16, 32 and 60 minutes apart. formbase
looks for due retries every 30 minutes, so a retry can come up to half an hour after its gap ends, and the last attempt comes about four
hours after the request ended. Every attempt carries the same bytes and the same id: the payload is frozen at the moment the
request ended, so a retry describes what happened then, not what the request looks like now. The destination URL and the signing secret
are read at each attempt, not frozen with it.
If the budget runs out — your receiver was down for the afternoon — the callback is not lost. The request gets a{' '}
Callback failed badge, the workspace owner is emailed once with the host, the reason and the attempt count, and the
answers stay readable through requests.get. To push it again, open the request in the{' '}
Requests page and press Replay, or call requests.replayCallback.
It re-sends the same frozen payload with the same id, which is exactly what a receiver that deduplicates wants.
A callback URL belongs to one request. When every request on a form should reach the same receiver, subscribe once instead: the formbase
apps for Zapier and n8n do this for you, and webhooks.create does it from code with request_completed,{' '}
request_expired or request_canceled as the event type. A subscription receives this same envelope, signed with
its own secret rather than the workspace request signing secret, with its own event id and its own retry budget. A request that has both a
callback URL and a matching subscription fires twice, once to each. Replay re-sends the callback only; a subscription
retries on its own and pauses after five failed attempts.
> Workflow tools hand you a hard-to-guess resume URL and it is tempting to treat that as proof. It is a bearer secret — it can leak into > logs, and it does not tell you the body was not tampered with. Verify the signature in the resumed branch as well. >
Click Requests in the sidebar, between Forms and Trash. Each card is one request: the recipient, the form it belongs to, its status, and — for a pending one — where it has got to.
Sort by Created, Last activity, or Name — click the field again to flip the direction. Search matches the recipient name, the recipient email address, and the external id. Filter by status (Pending, Completed, Expired, Canceled), by outcome (Approved, Declined, Changes requested), by form, or by{' '} Callback failed when you are hunting for deliveries to replay. Test requests are hidden until you tick{' '} Show test requests in the same filter.
Two shortcuts land you here pre-filtered: Open Requests on the Share sheet's Requests card, and the "pending requests" link above a form's submissions list.
Click a card to open the drawer. Its address bar keeps the request id, so you can share the link with a colleague or bookmark it.
> It is derived from the request's own timestamps plus its delivery attempts. Delivery rows are cleaned up after 30 days, so an old > request's timeline thins back to the milestones — created, opened, completed — while the email and callback lines drop away. >
Cancel, replay, and copy link work on every plan. Cancelling asks first; after it, the recipient sees a withdrawn notice instead of the form. Everything else acts immediately and confirms with a toast.
Try it yourself on the Share sheet's Requests card creates a request in{' '} test mode from the Manual tab's draft and shows you its link. Nothing is emailed: open the link yourself to walk through the form as the recipient would. The request shows in this list, with a Test badge, once you tick Show test requests.
Every rejection carries two things: a code for the kind of failure, and a details.reason for the specific cause.
Branch on the code; read the reason to know what to fix. Where it helps, details also names the offending field,
the keys that would have been accepted, or the option keys a choice question takes.
The request methods use four codes: VALIDATION_ERROR (the call was wrong), CONFLICT (the request is in the wrong
state, or an idempotency key was reused), NOT_FOUND, and UPGRADE_REQUIRED (a plan gate or the monthly
allowance). Calling too fast returns RATE_LIMITED instead, with retryAfterMs — see{' '}
the rate limit.
Open the request in the Requests page and read the timeline. The first invitation line tells you which case you are in.
> ℹ️ **No timeline entry at all** >
> Then no email was ever asked for. The request was created with delivery: "none" — deliver the link yourself, or create a
> new request with delivery: "email".
>
Invitations and reminders share a budget of ten emails per day per form and recipient address, and a request never emails its recipient more than nine times in its life — one invitation and up to eight reminders.
The request drawer's Callback section shows the URL and the outcome. Callback failed after N attempts means formbase tried and gave up — eight attempts over about four hours.
> Almost always the raw body. If you parse the JSON and re-serialize it before hashing, the bytes differ and the signature will never > match. Hash the body exactly as it arrived. The other common cause is a regenerated signing secret that the receiver has not picked up — > there is no grace period. >
requests.create began refusing a key with UNKNOWN_FIELD_KEY. A published field key disappeared: someone
retyped it, or deleted the question and added a new one in its place. Retitling is safe; both of those are not. Type the old key on the
field (toolbar key icon → Keys) and publish again. Publish warns before this happens — see{' '}
When a published key is about to disappear.
fields.list{' '}
marks them prefillable: false.
idempotencyKey. Pass the execution
id as the key.
A channel is one of the two ways a published form reaches people. You choose it in the Share sheet:
Nothing about the form changes between the two. The same questions, logic, theme and settings serve both. A form can use both at once, and a form's channel is never a setting you declare — it is simply observed from the share links and requests that exist.
A request is addressed, so it can carry things a public link cannot:
?utm_source=… style values seed the form's
hidden fields and nothing else. This is the only way to pass data into a public link, and it
is why hidden fields exist.
> Request links do not read ?param= values at all. The recipient holds that link, so honouring parameters would let them
> rewrite what the caller set. On a request, the caller's context is the only source.
>
Your workspace has one monthly allowance shared across both channels. A submission collected through a share link spends one unit, and{' '} creating a request spends one unit — whether or not the recipient ever answers. The submission a request collects is already paid for by the request and is not counted again. See{' '} limits & quotas.
The submissions table has a Source column showing Public link or Request for each row, and you can filter by it. Analytics covers both channels together with a channel filter rather than separate pages, and adds a request funnel when requests exist. See analytics & insights.
> ℹ️ **Step by step in Zapier and n8n** >> Send public link submissions to another app sends each submission to email, Sheets, > Slack or a CRM through Zapier. Send a request from a Zap does the same for requests. The > n8n guides do both in a self-hosted n8n. >
A share link is how respondents get to your form. Each link has its own 8-character code and its own settings, so you can create separate links for different campaigns, channels, or audiences.
> The Share panel has two tabs. Public link — this page — is one link anyone can open. Requests sends > the form to one named recipient who submits once. See creating requests. >
Link name and Custom URL sit in the always-open Link section. The rest live in three collapsible sections below it: Appearance, Limits, and Query parameters.
The Query parameters section bakes default values into the link URL. formbase always shows the three UTM fields it reads
for analytics (utm_source, utm_medium, utm_campaign), plus a row for every hidden field on the
form. Fill in a value, leave the rest empty, and formbase appends it to the copied link and the QR code automatically. Embed and popup
snippets don't include these defaults, and a parameter named lang is ignored because the default language uses it.
This saves you from editing the URL by hand. Set utm_medium to "email" on your newsletter link once, and every copy carries it.
A hidden-field value set here also fills any visible field that uses that hidden field as its{' '} default value, so one link can pre-fill answers for a whole audience.
> ℹ️ **What gets saved where** >> Default parameters are added to the link URL, not to each response. The three UTM fields feed the{' '} > Traffic Sources chart in Analytics. To save a > value on every submission, add a hidden field with the same name (for example utm_source) — > it then shows in both the submissions table and analytics. >
Click Revoke in the Share panel footer. Anyone who opens the link then sees "Link revoked", and the link stops taking new responses. Its settings can't be changed while it's revoked. Changed your mind? Click Re-activate in the same footer. Revoking keeps the responses the link already collected, and it doesn't affect the other links for the same form.
Moving a form to Trash revokes all of its links. Restoring the form leaves them revoked until you click Re-activate on each one.
Delete a link from the ⋯ menu in the Share panel footer. If the link has no submitted responses yet (unfinished drafts don't count), it's removed for good.
If the link already has responses, formbase revokes it instead of deleting it. A prompt explains this before anything happens. Revoking keeps the existing data — your analytics and per-channel breakdown stay intact — but stops the link from taking new responses. This way you never lose responses or the channel they came from just by clearing out an old link.
Creating a separate link per channel lets you compare performance in the Analytics tab. Filter by share link to see views, submissions, and bounce rate for each channel independently. Common patterns:
Append UTM parameters to any share link URL to tag traffic for the Analytics tab's Traffic Sources chart. formbase reads three standard parameters:
``` https://forms.yoursite.com/feedback?utm_source=linkedin&utm_medium=social&utm_campaign=product-launch ```Source and medium are matched case-insensitively; campaign names keep their case. Without UTM parameters, formbase falls back to the browser referrer to classify traffic — but some platforms strip referrers, so UTM parameters give you reliable attribution. See{' '} How traffic sources are classified for the full rules.
To skip editing the URL by hand, set these as defaults in the link's Query parameters section.
Every share link can generate a QR code. It encodes exactly the URL the Copy link button gives you, including any query parameter defaults and the language pin, so expiration, response limit, and analytics all apply the same way.
Print it on a sign, a slide, packaging, or a receipt. Create one share link per placement (for example qr-event-london,{' '}
qr-product-package) so the Analytics tab shows responses per placement.
> Always scan the printed QR with the camera you expect respondents to use. Some printers crush dark areas — test at the intended print > size before distributing. >
When a share link is posted on social media or messaging apps, formbase generates an Open Graph preview with the form title and description. You can customize the preview title, description, image, and favicon per form under{' '} Appearance → Share link preview in the Share panel.
If you use a custom domain, you can also set domain-level defaults that apply to all forms on that domain. A "Hide from search engines" toggle adds a noindex tag, at the domain level or per form (the form setting wins). On the default formbase.so domain, search engine indexing is always disabled, so the toggle only takes effect on custom domains.
> 💡 **Test your link before distributing** >> Open the share link in an incognito window to experience exactly what respondents will see — including authentication gates, password > prompts, and the default language setting. >
Both snippets are built from the share link you have selected, so they carry that link's expiration, response limit, custom domain, and analytics. Create the link first — see share links. Query parameter defaults set on the link are the one exception: they are not added to embed or popup snippets.
An inline embed places your form directly inside a page using an iframe. Open the Share panel, click the{' '} Embed card, and copy the generated snippet into your site's HTML.
Click View code to see the generated HTML. Use Copy embed code to copy it, then paste it into your site.{' '} Reset to defaults restores every setting.
With auto-resize off, the snippet is a single iframe pointing at your share link with ?embed=1 appended. The display options
add their own parameters: hideCover=1, hideLogo=1, and theme=dark or theme=light.
With auto-resize on, the iframe is wrapped in a div and followed by a script. The embedded form posts a{' '}
formbase:resize message on every height change; the script checks the message origin and the sending frame, then sets the
iframe height. Nothing else on your page is touched, and no external script is loaded.
A popup shows your form as an overlay on top of your page. Open the Share panel, click the Popup card, set up your options, and paste the generated code snippet into your site.
Choose how the popup opens:
Click View code to see the generated snippet. Use Copy popup code to copy it, then paste it into your site. Reset to defaults restores every setting.
The popup snippet is one self-contained {'