Create a form and a request in one call
When your workflow or AI agent decides what to ask, it can send the questions with the request. Formstep builds the form, publishes it, and sends the request in one call.
When to use it
A request usually points at a form you built in the editor. When the questions depend on the case, for example an agent on the phone
learns that a recipient still has to upload two payslips and pay a fee, send form instead of formId. Formstep
builds an ordinary form from the blocks, publishes it, and creates the request. Either both are created or neither is. This is an
inline form.
Use formId for a form you send to many people in the same shape. Use form when a workflow decides what to ask.
The call
curl -X POST https://api.formstep.io/api/v1 \
-H "Authorization: Bearer $FORMSTEP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"method":"requests.create","params":{
"form":{
"name":"Documents and fee for your mortgage application",
"logo":{"type":"image","url":"https://acme.example/logo.png"},
"cover":{"type":"color","color":"#0f4c81"},
"emoji":"🏦",
"blocks":[
{"type":"paragraph","content":"Upload the two documents and pay the processing fee."},
{"type":"file","key":"payslips","title":"Your last two payslips","maxFiles":2},
{"type":"payment","key":"fee","title":"Processing fee","amount":40,"currency":"EUR"},
{"type":"scheduleAppointment","key":"call","title":"Pick a time for our call","eventType":"15 min call"}]},
"recipient":{"email":"ada@example.com","name":"Ada"},
"delivery":"email",
"idempotencyKey":"call-8841"}}'All other request options work as usual. The response adds the form and the keys its answers come back under:
{
"id": "kd7...",
"status": "pending",
"url": "https://form.formstep.io/r/rq_...",
"form": {
"id": "j58...",
"name": "Documents and fee for your mortgage application",
"reused": false,
"fields": [
{ "key": "payslips", "type": "file" },
{ "key": "fee", "type": "payment" },
{ "key": "call", "type": "schedule-appointment" }
]
}
}To prefill a choice question in the same call, send option keys, as for any form. An option’s key is derived from its label, so
Self-employed becomes self_employed. A label instead of a key fails with INVALID_PREFILL_VALUE,
which lists the valid keys.
The form object
| Field | What it does |
|---|---|
| name | Required, 1 to 100 characters. The name on the dashboard and in the browser tab, the same for every recipient. Personalise through prefill and context, never through the name. |
| blocks | Required, 1 to 200 blocks, in the order the form shows them. The blocks in a row's columns and a repeating group's fields count too. |
| language | The form's UI language. Default: English. |
| logo, cover, emoji | Your brand, sent with every request. logo: { "type": "icon", "name": "HeartIcon" } or { "type": "image", "url": "https://…" }. cover: { "type": "color", "color": "#0f4c81" } or { "type": "image", "url": "https://…", "offsetY": 50 }. emoji: one emoji beside the name. See below. |
| theme | A look per color mode: { "light": "solarized-light", "dark": "dracula" }. Each mode takes a preset id, free on every plan, or a whole theme object as the MCP tool formTheme_set takes it, which needs the Pro plan. |
| settings | The form behaviour the settings page sets: branding, a verified email domain (emailDomainId), owner and respondent emails with their copy, the reminder schedule, the invitation email, the redirect and retention. Fields by key: respondentNotificationTo is the key of an email question, redirectQueryParams maps a parameter to a field key. Email copy is plain text with {{key}} placeholders. No password or sign-in gate and no share-link setting such as bot protection or edits after submit: a request carries its own link. Any other key is refused. |
| translations | The form in more languages, with a language switcher for the recipient: { "de": { "Your name": "Ihr Name" } }, every string of the form keyed by the text as the blocks wrote it. See below. |
Blocks
A block takes the parameters of the matching editor_insert* tool on the MCP server,
without formId and after, plus an optional key. Without one, the key is derived from the title, so
“Full name” becomes full_name. A parameter the block does not know is refused rather than dropped.
| Kind | Types |
|---|---|
| Questions | text, contact, number, date, time, radio, checkbox, select, switch, rating, linearScale, matrix, ranking, pictureChoice, file, signature, decision |
| Documents | documents, to show files the recipient reads or signs |
| Payments and bookings | payment, scheduleAppointment |
| Content | header, paragraph, image (src URL), embedded, table (cells fills it), list, row (columns: the blocks of each column, 2 to 6 columns, and widths in percent). An optional key lets logic show or hide the block. |
| Structure | pageDivider, repeatingGroup (fields: the member questions as blocks, each with its key), hiddenField (key required: it is the context key), calculatedField (its name is its key), logic |
| AI Validation | aiValidation (sentence: the rule, naming the answers it reads as {{key}}, the questions above it and hidden fields; key: optional, the key its result gets in data.aiValidations) |
Header and paragraph text, list items and table cells read **bold**, *italic*,
~~struck~~, <u>underlined</u>, `code`, $$equation$$,
[text](https://url), <span color="red" background="yellow" font="font-georgia" size="1.25">…</span> with
the editor toolbar’s colors, fonts and sizes, and {{key}}, which shows the value of the field with that key. Marks nest;
an unmatched marker is text. A column of a row holds any block except pageDivider, logic, hiddenField, calculatedField, aiValidation,
repeatingGroup, row and scheduleAppointment. A decision block takes its title and labels for
approve, decline and changes in the form’s language.
An aiValidation block checks the answers its sentence names against it with AI while the recipient fills in the form, and
shows a note when they do not fit; the recipient can still submit. For example,
{ "type": "aiValidation", "key": "invoice_check", "sentence": "{{invoice}} is an invoice for order {{order_number}}, dated in 2026." } right after an invoice file question, with order_number a hidden field the request’s context fills. Its result
arrives as data.aiValidations.invoice_check. See AI Validation.
Blocks refer to each other by key, never by element id: a logic block takes its rule as
editor_insertLogic does, with field keys where it wants ids, option labels as condition values and page names for
setNextPage.
Payments
A payment block takes amount and currency, or amountFromField: the key of a hidden
field or a number question. With a hidden field, each request passes its own amount in context, so one form charges every
recipient their own amount:
{
"form": {
"name": "Pay your invoice",
"blocks": [
{ "type": "hiddenField", "key": "invoice_total" },
{ "type": "payment", "key": "invoice", "title": "Invoice", "currency": "EUR", "amountFromField": "invoice_total" }
]
},
"recipient": { "email": "ada@example.com" },
"context": { "invoice_total": 1200 }
}A number question that sets the amount must be required and have a min above 0, so nobody reaches checkout with an empty,
zero or negative amount.
The form charges through the workspace’s default Stripe account, or the one connection names. A person
connects Stripe once for the workspace; an automation cannot. Without a
usable account the call fails and tells you where to connect it.
Bookings
A scheduleAppointment block takes eventType, the title or id of an event type on the workspace’s
Cal.com connection, and connection when the workspace has several. Formstep looks the
event type up when the request is created. prefill: { name, email } names the questions whose answers fill in the
booking.
Your brand
An automation has no way to know your look, so you set it up once and the automation sends it with every request: logo,
cover and emoji beside the blocks, and theme for the colors and type.
| Field | Takes |
|---|---|
| logo | An icon from the builder, { "type": "icon", "name": "HeartIcon" }, or your own image, { "type": "image", "url": "https://…/logo.png" }. |
| cover | A solid color, { "type": "color", "color": "#0f4c81" }, or an image, { "type": "image", "url": "https://…/cover.jpg", "offsetY": 50 }, where offsetY from 0 to 100 picks the part of a tall image that shows. |
| emoji | One emoji, shown beside the form name. |
Every image, the src of an image block included, must be a public JPEG, PNG or WebP. Formstep fetches each once when the form
is created and keeps its own copy, so the form and the PDFs of its submissions outlive the source. A URL that cannot be fetched fails the
call with INLINE_FORM_IMAGE_UNREACHABLE naming the field. Without a brand, the form looks like a new form from the dashboard.
Translations
translations publishes the form in more languages at once, and the recipient gets the language switcher a translated form
has: { "de": { "Your name": "Ihr Name", "Order for **{{customer}}**": "Bestellung für **{{customer}}**" } }. Every string
of the form must be there, keyed by the text exactly as the blocks wrote it: question titles, options, placeholders, headers, paragraphs,
list items, table cells, group names and button labels. Markup and {{key}} stay as in the source. A string missing fails
the call with INLINE_FORM_TRANSLATION_MISSING, which lists the strings to add. Email copy is not translated.
Repeat calls reuse the form
A later call with the same form object reuses the same form, and the response says "reused": true. A hundred
requests with one definition make one form, with one set of answers to export. Changing a block, the name, the brand or a translation
makes a new form; requests already sent keep the form they were created with. A form you close for submissions or unpublish is not reused:
the next call with the same definition makes a new one.
Personalise through prefill and context
Put the recipient’s name, the invoice number or the amount in prefill and context, never in the form name, a
title or a fixed amount. A definition that changes per recipient makes a new form per recipient. Set key on every question:
a derived key follows the title, so a retitled question answers under a new key. A workspace makes at most 300 new inline forms a day,
and calls that reuse a form do not count.
When a call makes a new form although an open inline form has the same block types in the same order, the response names that form in
form.similarForm. Usually a changed name or title caused it. To read every answer to one inline form, list its submissions
instead of reading each request.
On the dashboard
An inline form belongs to its request, so it lives on the request’s page rather than on the Forms page. The page lists the answers by
field key, and Preview opens the form as the recipient saw it, filled in with their answers. A link to an inline form’s
editor or submissions opens the Requests page filtered to that form instead. Agents still see inline forms in form_list, and
the API refuses to change one.
An inline form has no share link, and none can be added: its requests are the only way in, and the callback carries every answer. It has no integrations either.
Because it has no page of its own, an inline form is never deleted by hand. It goes with its last request: when you delete a request, or the form’s retention window takes it, and no other request still uses the form, the form is deleted too.
When the call is refused
Each refusal carries a details.reason your automation can branch on, and nothing is created. All of them are
VALIDATION_ERROR except the daily limit, which is RATE_LIMITED.
| Reason | What to do |
|---|---|
| FORM_SOURCE_MISSING / FORM_SOURCE_AMBIGUOUS | Send exactly one of formId and form. |
| INLINE_FORM_INVALID | A block or a field of form fails its schema. details.issues names each one. |
| INLINE_FORM_DUPLICATE_KEY | Two blocks resolve to the same key. Give one of them its own key. |
| INLINE_FORM_UNKNOWN_KEY | A logic rule, amountFromField or a booking prefill names a key no block has. |
| INLINE_FORM_AMOUNT_MISSING | Pass a positive number for the amount field in context. |
| INLINE_FORM_DAILY_LIMIT | The workspace made 300 new inline forms today. This one comes back as RATE_LIMITED with retryAfterMs; calls that reuse a form keep working. |
| INLINE_FORM_CONNECTION_MISSING | Stripe or Cal.com is not connected. details.connectUrl is the page a person opens once to connect it. |
| INLINE_FORM_EVENT_TYPE_NOT_FOUND | No Cal.com event type matches. The error lists the ones there are. |
| INLINE_FORM_IMAGE_UNREACHABLE | An image URL is no public JPEG, PNG or WebP. details.field names it. Use a URL anyone can open, or drop the image. |
| INLINE_FORM_TRANSLATION_MISSING | A translation lacks strings the form has. details.missing lists them, as written in the blocks. |
| INLINE_FORM_PUBLISH_ISSUES | Publishing refused the form, for instance an event type that cannot be booked from a form. |
From an agent
On the MCP server, request_create takes the same form. Before building a form with a payment or a booking, an
agent reads workspaceConnection_list: it shows the connected Stripe and Cal.com accounts and, when one is missing, the link a
person must open once. scheduler_listEventTypes lists the event types a booking can name, and
paymentConnection_setDefault changes the default Stripe account. Over the REST API the same three are
connections.list, scheduler.listEventTypes and paymentConnections.setDefault.