Create requests from code
Two calls send a request: fields.list tells you the form's field keys, and requests.create sends the form to one recipient with the values you already know.
To try it end to end with curl first, follow Send a request with the REST API. Every parameter is listed under Requests in the API reference. What a request is, and what the recipient sees, is explained in Requests.
Copy a call from the New request sheet
Open a published form, click New request (the paper plane icon), and fill in the sheet as if you were sending it by hand. Then open the ⋯ menu at the bottom of the sheet and pick From an automation. It shows the form id and the same request as a curl call or an MCP prompt, with this form's real field keys. OAuth and API Keys in the same menu opens the page where you create the API token.
Only a published form that is open for submissions takes requests. A form that was never published fails with
FORM_NOT_PUBLISHED, a closed one with FORM_CLOSED.
List the fields
fields.list returns every field of the form's current published version, with its key, its type and a few flags. Read the keys from it instead of guessing them from question titles. The flags tell you where a value goes:
| The field shows | What you send |
|---|---|
| prefillable: true | A value in prefill, in the shape its type asks for (see value shapes below). |
| options, or rows and columns | The option key, not the label. The label is there so you can match the choice you know to its key. |
| context: true | A hidden field. Its value goes in context, never in prefill; in prefill it fails with UNKNOWN_FIELD_KEY. |
| calculated: true | Nothing. The form works the value out, and you read it back under its key in answers. |
| prefillable: false (and no other flag) | Nothing. File upload, signature, payment, appointment booking and Documents blocks are for the recipient. |
| type: "group", repeating: true | A repeating group. Its members are listed under members, and you send a list of items. |
Field keys
A field key is the name your code uses for one field, such as company_name. Authors see and edit keys in the editor's
Fields tab; see Field keys. Until someone edits it, a key comes from the question
title: lowercase, accents removed, every space or symbol turned into an underscore, up to 64 characters.
| Question title | Key |
|---|---|
| Company name | company_name |
| What is your VAT number? | what_is_your_vat_number |
| Prénom | prenom |
| Søknad om støtte | soknad_om_stotte |
| الاسم الكامل | field_3: no Latin letters or digits, so the key is field_ plus its position |
When two fields would get the same key, the later one gets
_2,_3and so on.A key set by hand uses letters, digits,
_,.and-, up to 64 characters. Keys are case-sensitive and unique within the form.From the first publish, a field keeps its key when the question is renamed, moved or given another type. A new type may need a new value shape. A duplicated question gets a new key.
A key disappears when someone types a new one, or deletes a question and adds another in its place. Callbacks then arrive without that answer, and
requests.createrefuses the old key withUNKNOWN_FIELD_KEY. Publishing warns first, and the MCP toolform_publishreturns the same messages inwarnings.Publishing stops on a clash: two fields with one key (questions, repeating groups, hidden fields and calculated fields share one set of keys), a key already published on another field, two options of one question with one key, or a hidden field whose parameter name is invalid or used twice. A dropped field key or option key is only ever a warning. The same checks run when the API or an agent publishes.
Each option of a choice question, and each row and column of a matrix, has an option key, derived from its label the same way and unique within its question. A workflow can branch on
answers.plan == "pro"whatever language the recipient used.
A repeating group has its own key, and so does each field inside it. The members are nested, never top-level:
{ "contacts": [{ "name": "Ada", "email": "ada@acme.com" }, { "name": "Grace", "email": "grace@acme.com" }] }A hidden field's key is also its URL parameter on a share link. A calculated field's name is its key, so its value comes back
as, for example, answers.total.
Create the request
{
"method": "requests.create",
"params": {
"formId": "j57...",
"recipient": { "email": "ada@acme.com", "name": "Ada" },
"prefill": { "company_name": "Acme", "company_size": "51_200" },
"readonly": ["company_name"],
"context": { "case_id": "CASE-9" },
"metadata": { "crm_record": "rec_88" },
"delivery": "email",
"externalId": "run-42",
"callbackUrl": "https://automation.example/webhook/resume-abc",
"idempotencyKey": "run-42"
}
}The reply carries the request id, its status (pending), the recipient's url,
deliveryStatus and expiresAt; the full response is under
requests.create. Store the url if you deliver the link yourself.
A request carries four kinds of values. What each one means for the recipient is explained in Prefill, locked fields and context. The API rules:
| Parameter | Takes | Rules |
|---|---|---|
| prefill | Answers by field key, for visible questions | The recipient can change them. Each value must fit its type. |
| readonly | A list of prefilled keys to lock | Every key must also be in prefill (READONLY_REQUIRES_PREFILL), and a locked required field needs a non-empty value (READONLY_REQUIRED_EMPTY). |
| context | Values for the form's hidden fields | Each key must be a hidden field on the published form (UNKNOWN_FIELD_KEY; a visible question gives CONTEXT_KEY_NOT_HIDDEN_FIELD). A value is a string, number or boolean. Request links ignore URL parameters, so the recipient cannot change it. |
| metadata | Any object of your own | Never reaches the form. Comes back in every callback and read. |
Value shapes
Send each value in the shape its type asks for. A wrong shape fails with INVALID_PREFILL_VALUE, and the error
names the key, the expected type and, for a choice question, the accepted option keys.
| Type | Send |
|---|---|
| text, email, phone, url, textarea | A string |
| number, rating, scale | A number |
| switch | true or false |
| date | "2026-03-04" |
| time | "09:30" or "09:30:00" |
| radio, select | The option key, not its label |
| checkbox, ranking, picture-choice | A list of option keys |
| matrix | An object of row key to column key: { "row_key": "column_key" } |
| group (repeating) | A list of at most 100 items: [{ "member_key": value }, ...] |
| file, signature, payment, schedule-appointment | Nothing; the recipient supplies these |
| any field with calculated: true | Nothing; the form works it out |
| documents | Nothing in prefill; use the documents parameter below |
Documents for one recipient
A Documents block shows files to the recipient. A request can add files for its one recipient, such as their own contract, below the files every recipient gets. You upload the file to a URL Formstep gives you, so the bytes do not pass through the API:
- 1
Reserve the upload
Call documents.create with the formId, a name, the contentType, the exact size in bytes and, if you like, a sha256. You get back an id and an uploadUrl that is valid for one hour.
- 2
Upload the bytes
PUT the file to uploadUrl with the same Content-Type.
- 3
Reference it on the request
Pass documents: [{ documentId, field?, name? }] on requests.create. Formstep checks the uploaded file before the request is created.
"documents": [
{ "documentId": "kn7...", "name": "Your lease contract" },
{ "documentId": "kn8..." }
]name replaces the display name for this request. Set field to the block's field key when the form has more
than one Documents block. One upload can be used by any number of requests. The accepted types and the size caps are listed under
documents.create. The callback lists the files this recipient was given, under the
block's field key.
Invitations and reminders
delivery: "email" has Formstep email the invitation; it needs recipient.email. The default,
"none", leaves the link to you. Invitations and reminders need Pro or Business. On Free, an invitation uses one of the
free invitations and goes only to your own address: when they are used
up the call fails with FREE_INVITATIONS_USED, and another address fails with FREE_INVITATIONS_OWN_ADDRESS_ONLY.
Your own address is the one you sign in with, or the one your API token or MCP connection belongs to, and spellings of one address count
as one: a +tag or dots in a Gmail address do not make a new one. A deduplicated call and a test request spend no free
invitation. The email itself, and how the reminder schedule runs, are explained in
Invitations, reminders and expiry.
Delivery status
| deliveryStatus | Meaning |
|---|---|
| not_requested | Created with delivery "none". You deliver the link. |
| queued | Accepted, not sent yet. |
| sent | Handed to the email provider. |
| failed | Formstep could not send it, usually a plan or address problem. Fix it, then send a reminder: it carries the same link. |
| bounced | The provider reported a hard bounce or a complaint. Create a new request for the right address; a reminder will not help. |
Email limits
One recipient address gets at most 10 emails a day from one form, invitations and reminders together. Over it, the call fails with
RATE_LIMITEDand reasonRECIPIENT_EMAIL_LIMITED.An account sends at most 30 invitations and reminders an hour in its first 7 days, then 200 an hour. Over it, the call fails with reason
INVITATION_RATE_LIMITED; retry afterretryAfterMs. A scheduled reminder over it goes out later.A request emails its recipient at most nine times: the invitation and up to eight reminders, manual and scheduled together. requests.remind sends one now, at least 10 minutes after the last.
The reminders parameter
Without reminders, a request with a recipient email follows the form's schedule when Send reminders is
on. reminders replaces that schedule for one request, and applies even when the setting is off:
Up to five offsets of idle time, in whole days, hours or minutes, each at least one hour:
["2d", "12h", "90m"].[]sends no reminders.A schedule of your own needs a recipient email and Pro or Business. A duplicate offset, an offset under one hour or a sixth step fails with
INVALID_REMINDER_SCHEDULE.
Other options
| Option | What it does |
|---|---|
| language | The language the form opens in and the invitation is written in: one of the form's published languages, or LANGUAGE_NOT_PUBLISHED. The recipient can still switch. |
| expiresAt | When the link stops working, in epoch milliseconds. Default 30 days out, at most 365 (EXPIRY_OUT_OF_RANGE). |
| externalId | Your own id for the request. Comes back on every read and callback, and filters requests.list. |
| callbackUrl | Where Formstep POSTs the event when the request ends. See Request callbacks. |
| domainId | The custom domain the link is minted on. Without it, the link uses the domain the form already has a share link on. Needed when there are several. |
| form | Build the form in the same call instead of sending formId. See Inline forms. |
Test requests
test: true runs the whole flow without a real recipient. The call is checked like a real one, the link opens and can be
completed, the callback fires with "test": true, and requests.get returns the answers. A test request:
- sends no invitation and no reminders, and costs nothing from the monthly allowance;
- keeps its answers out of the inbox, submission counts, exports, integrations, subscriptions and notifications;
is hidden on the Requests page until you turn on Show test requests, and left out of
requests.listunless you passincludeTest: true;closes within 24 hours, whatever
expiresAtsays.
On Free, a workspace can create 10 test requests a day. The next fails with RATE_LIMITED and reason
TEST_REQUEST_LIMIT_REACHED.
Idempotency
Send the same idempotencyKey with the same body, and you get the original request back with deduplicated: true:
no second request and no second email. The same key with a different body fails with IDEMPOTENCY_CONFLICT. Keys apply per
workspace for 30 days. In a workflow tool, use the execution id as the key, so a retried run finds the request it already created.
What a request costs
Each request spends one unit of the workspace's monthly allowance when it is
created, answered or not. When the allowance is spent, requests.create fails with UPGRADE_REQUIRED and reason
MONTHLY_ALLOWANCE_REACHED. Requests already created can still be answered.
Rate limit
requests.create and documents.create share a limit of 60 calls a minute per token, on top of the
general limit. A call over it fails with RATE_LIMITED and can be retried
after retryAfterMs.
Read the answers
Wait for the callback, or read the request with
requests.get. Once status is completed, it holds two maps keyed
by field key: answers for code (option keys, numbers, file lists with download URLs) and display for people
(labels, file names). A request is deleted once the form's retention window passes after it ends, so read what you need when it ends.
Outcome
status says whether the request finished; outcome says what the recipient decided. A
decision question is a radio keyed decision whose options have the keys
approve, decline and changes. When the recipient picks one of the three, the request completes with
that outcome:
"request": { "id": "kd7...", "externalId": "run-42", "status": "completed", "outcome": "changes" },
"answers": { "decision": "changes", "reason": "Please split the line items per site." }The outcome is on
data.request.outcomein the callback and next tostatusonrequests.get. The verdict is also an ordinary answer underdecision.requests.listtakes anoutcomefilter, which implies completed requests only.There is no outcome when the recipient picks a fourth option, when the option keys were changed (publishing warns about that), when the decision question sits inside a repeating group, or when the request expired or was canceled.
From an AI agent
Over the MCP server, the same calls are the tools fields_list,
request_create, request_get, request_list, request_cancel, request_remind
, request_replayCallback and document_create, with the same parameters.
fields_listadds ausageline to each key that names the argument it belongs in. Read it again afterform_publish.Every request tool's result has a
nextline telling the agent what to do from there.The ids for
domainIdcome fromformShareLink_list, asavailableCustomDomains.editor_insertDecisionQuestionadds a decision question; it takes a title and the three labels in the form's language.An agent with nobody to set up a receiver leaves out
callbackUrland pollsrequest_getevery few minutes untilstatusleavespending, then readsoutcome. See Callbacks or polling.A
request_listpage can come back with no items andhasMore: true. The list goes on, so pass the cursor back and keep going.