API reference
One endpoint and a JSON body reach every Formstep method: forms, submissions, share links, requests, webhooks, workspaces and translations.
Single endpoint, many methods
Every method is POST https://api.formstep.io/api/v1 with a JSON body
{"method": "...", "params": {...}} and an Authorization: Bearer fs_... header. See the
Developers overview for authentication, and API tokens for the
token itself.
The same reference is available as an OpenAPI 3.1 description for code generators, API clients, and agents.
Methods
Conventions
paramsmay be omitted; it defaults to{}. An unknown method is404 METHOD_NOT_FOUND.A token is bound to one workspace. Naming another workspace, or a form in one, is
403 FORBIDDENeven when you belong to both.List methods paginate. They return
{ items, nextCursor, hasMore }; most also returncanPaginate, which isfalsewhenhasMoreis true but no cursor can resume (fuzzy search). PassnextCursorback ascursor.limitis 1 to 100, default 20, except forrequests.list, whose default is 25.The rate limit is 120 calls per minute per token, shared with the MCP server;
requests.createanddocuments.createalso share 60 per minute. Failed authentication is limited separately, 30 per 15 minutes per IP, after which bad tokens seeRATE_LIMITEDinstead ofUNAUTHORIZED.A request body can be at most 1 MiB. Larger bodies are rejected with
VALIDATION_ERROR.The path carries the API version. Breaking changes ship as
/api/v2; new methods and new response fields do not.
Forms
forms.list
List forms in a workspace. Supports cursor pagination and optional fuzzy name search.
workspaceIdstringRequiredWorkspace ID.
folderIdstring | nullFilter by folder. Pass null for root-level forms only. Omit to list all.
querystringFuzzy name search. Results capped at limit; not cursor-paginated.
limitnumberDefault 20Page size (1 to 100).
cursorstringPagination cursor from a previous response.
curl -X POST https://api.formstep.io/api/v1 \
-H "Authorization: Bearer fs_..." \
-H "Content-Type: application/json" \
-d '{
"method": "forms.list",
"params": { "workspaceId": "ws_abc123" }
}'{
"ok": true,
"data": {
"items": [
{
"id": "frm_abc123",
"name": "Contact Form",
"folderId": null,
"workspaceId": "ws_abc123",
"isPublished": true,
"publishedAt": 1714041851000,
"unpublishedAt": null,
"createdAt": 1714041800000,
"lastEditedAt": 1714042000000,
"emoji": null
}
],
"nextCursor": null,
"hasMore": false,
"canPaginate": false
}
}forms.get
Get full details for a single form, including questions, cover, logo, and a preview URL.
formIdstringRequiredForm ID.
curl -X POST https://api.formstep.io/api/v1 \
-H "Authorization: Bearer fs_..." \
-H "Content-Type: application/json" \
-d '{
"method": "forms.get",
"params": { "formId": "frm_abc123" }
}'{
"ok": true,
"data": {
"id": "frm_abc123",
"name": "Event Feedback",
"folderId": null,
"workspaceId": "ws_abc123",
"isPublished": true,
"publishedAt": 1714041851000,
"unpublishedAt": null,
"createdAt": 1714041800000,
"lastEditedAt": 1714042000000,
"emoji": null,
"questions": [
{
"id": "q_1",
"type": "email-input",
"inputType": "email",
"title": "Your email",
"metadata": null
},
{
"id": "q_2",
"type": "rating-input",
"inputType": "rating",
"title": "Overall experience",
"metadata": { "kind": "rating", "maxStars": 5 }
}
],
"hasContent": true,
"cover": null,
"logo": null,
"isDeleted": false,
"trashedAt": null,
"previewUrl": "https://formstep.io/preview/abc..."
}
}forms.create
Create a new empty form. Returns the form and a preview URL.
namestringRequiredForm name (1 to 255 characters).
workspaceIdstringRequiredWorkspace ID.
folderIdstringPlace the form in a folder. Omit to create at workspace root.
curl -X POST https://api.formstep.io/api/v1 \
-H "Authorization: Bearer fs_..." \
-H "Content-Type: application/json" \
-d '{
"method": "forms.create",
"params": {
"name": "Contact",
"workspaceId": "ws_abc123"
}
}'const res = await fetch('https://api.formstep.io/api/v1', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FORMSTEP_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
method: 'forms.create',
params: { name: 'Contact', workspaceId: 'ws_abc123' },
}),
})
const { ok, data } = await res.json()import os, requests
res = requests.post(
"https://api.formstep.io/api/v1",
headers={"Authorization": f"Bearer {os.environ['FORMSTEP_TOKEN']}"},
json={
"method": "forms.create",
"params": {
"name": "Contact",
"workspaceId": "ws_abc123",
},
},
)
data = res.json(){
"ok": true,
"data": {
"id": "frm_new123",
"name": "Contact",
"workspaceId": "ws_abc123",
"folderId": null,
"isPublished": false,
"createdAt": 1714041851000,
"previewUrl": "https://formstep.io/preview/abc..."
}
}forms.update
Update form metadata: name, folder, emoji, cover, or logo. Does not update form content (use the MCP editor tools for that).
formIdstringRequiredForm ID.
namestringNew form name (1 to 255 characters).
folderIdstring | nullMove form to a folder. Pass null to move to workspace root.
emojistring | nullForm emoji (max 10 characters). Pass null to clear.
coverobjectCover. {"type": "color", "color": "#ffffff"}, {"type": "image", "url": "https://...", "offsetY": 50} (
offsetY 0 to 100, default 50), or {"type": "none"} to remove. Image URLs must be http(s) or a
data:image URI.
logoobjectLogo. {"type": "icon", "name": "HeartIcon"}, {"type": "image", "url": "https://..."}, or
{"type": "none"} to remove. Icon names are fixed: QuestionMarkIcon, ListBulletsIcon,
ChartBarIcon, ClockCountdownIcon, HeartIcon, LightbulbIcon,
CheckCircleIcon, MagnifyingGlassIcon, TrendUpIcon, EnvelopeIcon,
PhoneIcon, CalendarIcon, LinkIcon, UsersIcon.
Pass at least one of the five updatable fields.
curl -X POST https://api.formstep.io/api/v1 \
-H "Authorization: Bearer fs_..." \
-H "Content-Type: application/json" \
-d '{
"method": "forms.update",
"params": {
"formId": "frm_abc123",
"name": "Updated Name",
"emoji": "📋"
}
}'{
"ok": true,
"data": {
"id": "frm_abc123",
"name": "Updated Name",
"folderId": null,
"emoji": "📋"
}
}cover and logo come back only when you sent them. A payload that matched the current state on every scalar field
adds noChange: true.
forms.publish
Publish a form so it can accept responses, and freeze its field keys into a new snapshot. Idempotent: an
already-published form returns success with alreadyPublished: true, and an unpublished form is republished from its last snapshot.
formIdstringRequiredForm ID.
A form with content blocks but no questions publishes with a warning. A form with no content at all cannot be published. Publishing does not create a public URL; call shareLinks.create for that.
forms.unpublish
Take a form offline. Respondents can no longer open it. Idempotent: a form that is not published returns alreadyUnpublished: true.
Reversible with forms.publish.
formIdstringRequiredForm ID.
forms.delete
Move a form to trash. Its active share links are revoked, so their public URLs stop serving.
formIdstringRequiredForm ID.
Restoring does not bring the links back
forms.restore returns the form, but the share links it revoked stay revoked. Mint new ones with
shareLinks.create. A form already in trash returns alreadyTrashed: true and keeps its original trash date.
forms.restore
Restore a form from trash.
formIdstringRequiredForm ID.
folderIdstring | nullWhere to restore it. Omit for its original folder, null for workspace root, or a folder ID.
A form that is not in trash returns alreadyRestored: true.
formSettings.get
Read a form’s behavior settings.
formIdstringRequiredForm ID.
Returns { settings, isDefault, availableEmailDomains, defaultFromAddress, payment }. isDefault is true when
the form has no saved settings row yet and you are seeing the defaults. availableEmailDomains holds the verified domain ids
you can pass as emailDomainId, and payment reports whether Stripe is connected (connecting it is a dashboard
step).
formSettings.update
Update a form’s behavior settings. A partial update: only the fields you send are written.
formIdstringRequiredForm ID.
language (BCP-47, default "en"), requireAuthentication, showBranding,
captchaEnabled, passwordEnabled, password (4 characters or more; a string implies
passwordEnabled: true, null clears the gate).
notifyOnSubmission, notificationEmails (array), selfNotificationSubject,
selfNotificationEmailBody, pdfGenerationEnabled, showViewSubmissionButton. Owner emails are not
translatable, so write them in the language you want.
respondentNotificationEnabled, respondentNotificationTo (the field id of an email question, or
null), respondentNotificationSubject, respondentNotificationBody,
respondentNotificationPdfEnabled.
respondentReminderEnabled, respondentReminderTo, respondentReminderSubject,
respondentReminderBody, respondentReminderRequiredFieldIds, and reminderSteps (idle offsets like
["1d","3d","1w"], at most 5, sorted and deduplicated on save, [] for none). The schedule applies to
abandoned public-link responses and to requests alike. Pro.
redirectUrl (http(s); null or "" clears), redirectQueryParams (
[{ paramName, fieldId }]), allowAnotherResponse (mutually exclusive with a redirect),
maxSubmissionsPerRespondent (0 = unlimited, max 1000), editAfterSubmit, maxEdits (max 3; 0 means
unlimited on Pro and Business, 3 on Free).
draftRetentionDays and submissionRetentionDays (0 to 36500, null reverts to the default).
Submission retention is Business, and setting it clears any fixed deletion date configured in the builder.
emailDomainIdstring | nullA verified email-domain id from formSettings.get, for a custom From address. null resets to the default
sender.
Subjects and bodies are plain text and accept {{variable}} placeholders; newlines become paragraphs. Customizing a
respondent subject or body makes it translatable, so its keys appear in translations.listEntries right away.
Submissions
submissions.list
List a form’s submissions, newest page first, with cursor pagination.
formIdstringRequiredForm ID.
includeDraftsbooleanDefault falseInclude responses that were started but never submitted. Drafts are a Pro feature: on Free only completed submissions are listed.
translationLanguagestringAttach stored AI translations of the answers under items[].translation.display, keyed like display.
items[].answers and items[].display always stay the original.
limitnumberDefault 20Page size (1 to 100).
cursorstringPagination cursor from a previous response.
{
"ok": true,
"data": {
"formId": "frm_abc123",
"formName": "Event Feedback",
"items": [
{
"id": "sub_xyz789",
"submittedAt": "2026-05-18 18:02:28",
"isCompleted": true,
"createdAt": "2026-05-18 18:02:19",
"answers": {
"email": "user@example.com",
"plan": "pro",
"contacts": [{ "name": "Ada" }, { "name": "Grace" }]
},
"display": {
"email": "user@example.com",
"plan": "Pro",
"contacts": "Ada, Grace"
}
}
],
"nextCursor": null,
"hasMore": false,
"canPaginate": false
}
}Same answers as webhooks and callbacks
Each item carries answers keyed by field key and display
with the same keys as readable text. A webhook payload, a
request callback and requests.get carry the same shape. A choice answer is its option
key, a repeating group an array of instances. Call fields.list for each key’s title and option labels. This method returns
no totals.
submissions.pdf
Get a link to one submission’s PDF. Built for the Zapier connector: it returns a result only when the form has an active Zapier integration configured to include the PDF, and the PDF was retained.
formIdstringRequiredForm ID.
submissionIdstringRequiredSubmission ID. Must belong to that form and be completed.
{
"ok": true,
"data": {
"url": "https://api.formstep.io/api/storage/...",
"filename": "formstep-submission-sub_xyz789.pdf",
"contentType": "application/pdf",
"byteLength": 148213
}
}submissions.sample
Build a sample submission payload for a form, without any real data. It is the exact shape a share-link submission delivery carries, so
connectors use it for field discovery; a request-born submission reaches a subscription as request.completed instead, sampled by
requests.sample. data.form.snapshotId is the form’s current published version, the same id live
events carry, or null while the form is unpublished.
formIdstringRequiredForm ID.
{
"ok": true,
"data": {
"id": "evt_example000000000000",
"type": "submission.completed",
"createdAt": "2026-05-18T19:00:00.000Z",
"apiVersion": "2026-09-24",
"test": true,
"data": {
"form": { "id": "frm_abc123", "name": "Event Feedback", "snapshotId": "js7abc123" },
"submission": {
"id": "sub_example000000000000",
"respondentEmail": "respondent@example.com",
"submittedAt": "2026-05-18T19:00:00.000Z",
"updatedAt": null,
"editCount": 0,
"pdfUrl": null,
"language": "en"
},
"answers": { "your_email": "john@example.com" },
"display": { "your_email": "john@example.com" }
}
}
}The webhooks reference explains each field of the payload.
Share links
Fields
fields.list
List every field of a form’s current published version, with the key to address each one by. Call this before requests.create instead of
hard-coding keys. See List the fields for what each flag means.
formIdstringRequiredForm ID. A form that has never been published has no field keys yet and answers published: false with no items.
curl -X POST https://api.formstep.io/api/v1 \
-H "Authorization: Bearer fs_..." \
-H "Content-Type: application/json" \
-d '{
"method": "fields.list",
"params": { "formId": "j57..." }
}'{
"ok": true,
"data": {
"published": true,
"items": [
{ "key": "company_name", "type": "text", "title": "Company", "required": true, "prefillable": true },
{
"key": "company_size", "type": "select", "title": "Company size", "required": false, "prefillable": true,
"options": [
{ "key": "1_50", "label": "1–50" },
{ "key": "51_200", "label": "51–200" }
]
},
{
"key": "satisfaction", "type": "matrix", "title": "How did we do?", "required": false, "prefillable": true,
"rows": [{ "key": "delivery_speed", "label": "Delivery speed" }],
"columns": [{ "key": "very_good", "label": "Very good" }, { "key": "poor", "label": "Poor" }]
},
{ "key": "case_id", "type": "hidden", "title": "Case", "required": false, "prefillable": false, "context": true },
{ "key": "total", "type": "number", "title": "Total", "required": false, "prefillable": false, "calculated": true }
],
"hasMore": false
}
}A repeating group is type: "group" with repeating: true and a members array. A Documents block
is type: "documents" and carries documents: [{ name }], the authored files every respondent already
sees.
Requests
A request assigns one published form to one person and calls you back when it ends. How the parameters fit together is explained in Create requests from code.
requests.create
Create a request. Spends one unit of the workspace’s monthly allowance, whether or not the recipient answers.
formIdstringThe published form to assign. Send this or form, not both.
formobjectBuild the form in this call instead:
{ name, blocks, language?, logo?, cover?, emoji?, theme?, settings?, translations? }. See
Create a form and a request in one call.
workspaceIdstringWhere an inline form goes. Defaults to the token’s workspace; any other is refused.
recipientobject{ email?, name? }. An email is required when delivery is "email"; otherwise it only
identifies the person in the Requests page and on their answers.
prefillobjectInitial answers by field key. The recipient sees them and can change them.
readonlystring[]Prefilled keys the recipient cannot change. Every key here must also appear in prefill, and a locked required field must be
prefilled with a non-empty value.
contextobjectValues for the form’s hidden fields, by field key. Trusted, unchangeable, and echoed back in the callback. An unknown key is rejected
with UNKNOWN_FIELD_KEY.
metadataobjectYour own bookkeeping. Never reaches the form; comes back in callbacks and reads.
languagestringOne of the form’s published languages. Defaults to the form’s own default.
deliverystringDefault none"email" to have Formstep send the invitation (needs a recipient email, and Pro or Business or one of a Free account’s
10 free invitations, which go only to the account’s own address), or "none" to deliver the link yourself.
remindersstring[]Override the form’s reminder schedule for this request: up to five offsets such as ["2d", "12h", "90m"], each at least
an hour. An empty array switches reminders off. See The reminders parameter.
expiresAtnumberEpoch milliseconds. Defaults to 30 days out; 365 days is the maximum.
callbackUrlstringWhere Formstep POSTs the callback when the request ends. HTTPS only, and the host must resolve to a public address.
externalIdstringYour own id for this request. Filterable in requests.list.
idempotencyKeystringRepeating it with the same body returns the original request with deduplicated: true. A different body is rejected. Keys
live 30 days.
domainIdstringMint the link on one of your custom domains.
documentsobject[][{ documentId, field?, name? }]: files handed to this one recipient, uploaded first with
documents.create.
testbooleanDefault falseA dry run: nothing is emailed, the callback carries "test": true, and the submission counts nowhere. The link closes
within 24 hours, and on Free a workspace may create 10 test requests a day.
curl -X POST https://api.formstep.io/api/v1 \
-H "Authorization: Bearer fs_..." \
-H "Content-Type: application/json" \
-d '{
"method": "requests.create",
"params": {
"formId": "j57...",
"recipient": { "email": "ada@acme.com", "name": "Ada" },
"prefill": { "company_name": "Acme" },
"readonly": ["company_name"],
"context": { "case_id": "CASE-9" },
"delivery": "email",
"externalId": "run-42",
"callbackUrl": "https://automation.example/webhook/resume-abc",
"idempotencyKey": "run-42"
}
}'{
"ok": true,
"data": {
"id": "kd7...",
"status": "pending",
"url": "https://form.formstep.io/r/rq_...",
"deliveryStatus": "queued",
"expiresAt": 1794787200000,
"createdAt": 1789379200000,
"externalId": "run-42",
"deduplicated": false
}
}Keep the url
url carries the one-time token. requests.get can usually rebuild it, but it comes back null for a
request created before the deployment had a request-token key. If you deliver the link yourself, store it when you create it.
deliveryStatus is one of not_requested, queued, sent, failed and
bounced; see Delivery status.
With form, the response also carries
form: { id, name, reused, fields: [{ key, type, options? }], aiValidations?, similarForm? }: the form that was built or
reused, the keys its answers come back under, and each choice question’s option keys for a later prefill.
aiValidations lists the keys its AI Validations report under in
data.aiValidations, when it has any. similarForm appears when the call made a new form although an open one has
the same block types in the same order: usually a changed name or title. Send the same definition every time to reuse one form.
requests.get
Get one request in full: status, outcome, what was prefilled, its timeline, and, once completed, answers and display keyed by field key, the same two maps the callback carries.
requestIdstringRequiredRequest ID.
{
"ok": true,
"data": {
"id": "kd7...",
"formId": "j57...",
"status": "completed",
"outcome": "approve",
"isTest": false,
"recipient": { "email": "ada@acme.com", "name": "Ada" },
"language": "en",
"externalId": "run-42",
"metadata": null,
"context": { "case_id": "CASE-9" },
"prefill": { "company_name": "Acme" },
"readonlyKeys": ["company_name"],
"delivery": "email",
"deliveryStatus": "sent",
"hasCallback": true,
"callbackFailedAt": null,
"submissionId": "kp2...",
"url": "https://form.formstep.io/r/rq_...",
"answers": { "company_name": "Acme", "decision": "approve" },
"display": { "company_name": "Acme", "decision": "Approve" },
"timeline": [
{ "id": "kd7...:created", "type": "created", "at": 1789379200000 },
{ "id": "kd7...:completed", "type": "completed", "at": 1789465600000 }
],
"expiresAt": 1794787200000,
"completedAt": 1789465600000
}
}outcome vs status
status says whether the request finished; outcome says what the recipient decided: approve,
decline, or changes. It is null on anything but a completed request whose recipient picked one of
the three, including a form with no decision question. The callback URL itself is never
returned; hasCallback only says whether one is set.
The example above is trimmed. A full response also carries workspaceId, formSnapshotId, createdVia,
documents, reminderStep, remindersSent, reminderDueAt, and the rest of the timestamps
(updatedAt, openedAt, startedAt, lastActivityAt, expiredAt,
canceledAt, canceledBy, cancelReason).
callbackFailedAt is set while this request's callback has run out of attempts, and cleared once one gets through or you
replay it. While it is set, the callback was never delivered, so this read is your only copy. Once the form's
retention window passes after a request ends, the request
is deleted and requests.get answers REQUEST_NOT_FOUND. An expired or canceled request is deleted without any
setting, 30 days after it ended by default, so read what you
need when its callback arrives.
timeline is derived, oldest first. Each entry has an id, an at, and a type:
created, invitation, reminder, opened, started, completed,
expired, canceled, callback. Delivery entries add deliveryStatus and
attemptCount, and callbacks add eventType. Delivery rows are kept 30 days, so older timelines thin back to the
timestamps.
requests.list
List requests in a workspace or on one form, newest first. Test requests are left out unless you ask for them. Each row names its form
in formName, and formOrigin is "inline" when a request built that form.
workspaceIdstringScope to a workspace. Give this or formId.
formIdstringScope to one form.
statusstringpending, completed, expired, or canceled.
outcomestringapprove, decline, or changes. Implies completed requests only.
externalIdstringYour own id, to find the request a run created.
includeTestbooleanDefault falseInclude requests created with test: true.
limitnumberDefault 25Page size (1 to 100).
cursorstringPagination cursor from a previous response.
{
"ok": true,
"data": {
"items": [
{
"id": "kd7...",
"formId": "j57...",
"formName": "Mortgage documents",
"formOrigin": "inline",
"status": "pending",
"outcome": null,
"recipient": { "email": "ada@acme.com", "name": "Ada" },
"externalId": "run-42",
"deliveryStatus": "sent",
"expiresAt": 1794787200000,
"createdAt": 1789379200000
}
],
"nextCursor": null,
"hasMore": false
}
}List items carry the same fields as requests.get minus url, answers, display, and
timeline, and each one carries isTest. Give workspaceId or formId; sending neither is
400 VALIDATION_ERROR with reason SCOPE_REQUIRED. outcome overrides status, since only
a completed request has a verdict.
requests.cancel
Withdraw a pending request. The link stops working, the recipient sees a withdrawn notice, and a request.canceled callback fires.
requestIdstringRequiredRequest ID.
reasonstringYour note for why, kept on the request and sent in the callback.
requests.delete
Delete a request and everything it gathered: the recipient’s submission and answers, uploaded files, the documents you attached, and its timeline and delivery history. Works in any status. A pending request’s link stops working, and no callback fires. Use it when a recipient asks you to erase their data; to withdraw a pending request and hear about it in your callback, use requests.cancel. Deleting takes an API token; an OAuth app, such as an AI agent connected over MCP, gets 403 FORBIDDEN with reason API_KEY_REQUIRED, whatever its scopes.
requestIdstringRequiredRequest ID.
When the request built its form (an inline form) and no other request uses that form, the form is
deleted too and formDeleted is true. Deleting cannot be undone.
requests.remind
Email the recipient now, without touching the reminder schedule. Needs a recipient email and a Pro or Business plan.
requestIdstringRequiredRequest ID. Must still be pending, and not a test request.
Manual reminders must be at least 10 minutes apart, and a request gets at most 8 reminders in total, manual and scheduled together. The
automatic schedule is untouched: reminderStep and reminderDueAt stay where they were.
requests.replayCallback
Re-send the callback a request fired when it ended, with the same payload and the same event id, so a receiver that already handled it can deduplicate. Use it after fixing a broken endpoint.
requestIdstringRequiredRequest ID. Must be completed, expired, or canceled.
{
"ok": true,
"data": { "dispatchId": "kf9...", "eventId": "evt_kf9..." }
}requests.sample
Build a sample request event for a form, without any real request. It is the exact envelope a request_* subscription created
with webhooks.create receives, so connectors use it for field discovery. A completed sample carries the
same example answers submissions.sample shows; an expired or canceled sample carries the request block
alone.
formIdstringRequiredForm ID.
eventTypestringRequiredWhich ending to sample, in the webhooks.create spelling. The envelope’s type is the dotted form.
request_completedrequest_expiredrequest_canceled{
"ok": true,
"data": {
"id": "evt_example000000000000",
"type": "request.completed",
"createdAt": "2026-05-18T19:00:00.000Z",
"apiVersion": "2026-09-24",
"test": true,
"data": {
"request": {
"id": "req_example000000000000",
"externalId": "order-1234",
"status": "completed",
"language": "en",
"recipient": { "email": "recipient@example.com", "name": "Sample Recipient" },
"metadata": { "source": "sample" },
"context": {},
"createdAt": "2026-05-18T18:00:00.000Z",
"completedAt": "2026-05-18T19:00:00.000Z"
},
"form": { "id": "frm_abc123", "name": "Event Feedback", "snapshotId": "js7abc123" },
"submission": {
"id": "sub_example000000000000",
"respondentEmail": "respondent@example.com",
"submittedAt": "2026-05-18T19:00:00.000Z",
"updatedAt": null,
"editCount": 0,
"pdfUrl": null,
"language": "en"
},
"answers": { "your_email": "john@example.com" },
"display": { "your_email": "john@example.com" }
}
}
}The request block and the outcome are documented under Request callbacks; the submission half
on the webhooks reference. Sample ids are the fixed placeholders shown above and
test is true, so a receiver can tell a sample from a live event.
documents.create
Reserve an upload for a file you will hand to one recipient through the form’s Documents block. You
upload the bytes outside this API: you get a presigned PUT URL, upload the file to it, and requests.create verifies the object before
the request exists.
formIdstringRequiredThe form whose Documents block will show the file. Scopes the upload to that workspace.
namestringRequiredDisplay name the recipient sees (1 to 200 characters). Overridable per request.
contentTypestringRequiredapplication/pdf or an image type: image/png, image/jpeg, image/webp,
image/gif, image/avif, image/bmp, image/tiff. Office documents are not accepted.
sizenumberRequiredExact byte length. Maximum 25 MB (26,214,400).
sha256stringHex digest of the bytes. Verified after upload when given.
{
"ok": true,
"data": {
"id": "kn7...",
"name": "Lease contract draft",
"contentType": "application/pdf",
"size": 412000,
"uploadUrl": "https://...",
"expiresAt": 1792198800000
}
}PUT the raw bytes to uploadUrl within the hour, with Content-Type set to the type you declared,
then reference the id from requests.create:
{
"method": "requests.create",
"params": {
"formId": "j57...",
"documents": [
{ "documentId": "kn7...", "name": "Your lease contract" },
{ "documentId": "kn8...", "field": "attachments" }
]
}
}fieldis the Documents block’s field key. Optional when the form has exactly one block; required with two or more.- The block’s authored documents stay; yours appear below them, for this one recipient.
- Caps: 25 MB per document, 100 MB of documents per request, 20 documents shown per block including the authored ones.
One upload can be referenced by any number of requests. An upload nobody references ages out. Bytes count against the workspace owner’s storage until the last request referencing them is stripped by retention.
Every failure here is 400 VALIDATION_ERROR. The reasons, from this method and from requests.create, are listed
under Documents in the error reference.
Connections
connections.list
The Stripe and Cal.com accounts a workspace has connected. Read it before building an inline form with a payment or a booking: a person
connects each service once, on the page connectUrl names, and an automation cannot.
workspaceIdstringRequiredWorkspace ID.
{
"ok": true,
"data": {
"connectUrl": "https://app.formstep.io/k57.../integrations",
"items": [
{ "provider": "stripe", "id": "pc7...", "name": "Acme Ltd", "status": "connected", "ready": true, "default": true },
{ "provider": "cal.com", "id": "oc7...", "name": "ada@acme.com", "status": "active", "ready": true }
],
"nextCursor": null,
"hasMore": false,
"canPaginate": false
}
}ready says whether forms can use the account now. default marks the Stripe account payments charge through
unless a payment block names another.
scheduler.listEventTypes
The Cal.com event types a booking block can name, and whether each can be booked from a form.
workspaceIdstringRequiredWorkspace ID.
connectionstringThe Cal.com connection id from connections.list. Required when the workspace has several.
Returns { connection: { id, name }, items: [{ id, title, durationMinutes?, bookable, reasons? }] }. Only a bookable event
type publishes; reasons says why the others are not.
paymentConnections.setDefault
Make a Stripe account the workspace default, the one payment blocks charge through unless they name another.
Only the workspace owner can change the default. A token of any other member gets 403 FORBIDDEN.
workspaceIdstringRequiredWorkspace ID.
connectionstringRequiredA Stripe connection id of that workspace, from connections.list.
Webhooks
webhooks.list
List webhook subscriptions for a form.
formIdstringRequiredForm ID.
{
"ok": true,
"data": {
"items": [
{
"subscriptionId": "int_abc123",
"formId": "frm_abc123",
"provider": "zapier",
"targetUrl": "https://hooks.zapier.com/...",
"eventType": "submission_created",
"status": "active",
"createdAt": 1714041851000
}
],
"hasMore": false
}
}webhooks.create
Subscribe a URL to form events: new or abandoned submissions, or requests on the form ending. The URL must use HTTPS.
formIdstringRequiredForm ID.
targetUrlstringRequiredHTTPS URL to receive webhook payloads.
providerstringRequiredWhich tool the subscription belongs to. It is a label for your own bookkeeping. There is no marketplace app to install, and every provider behaves the same.
zapiermaken8neventTypestringDefault submission_createdEvent type to subscribe to. The three submission_* types deliver the submission payload:
submission_created a first submission, submission_updated a respondent’s edit, and
submission_abandoned an idle draft. The three request_* types deliver the matching
request event whenever a request on the form ends that way, signed with this subscription’s
secret; test requests reach no subscription.
submission_createdsubmission_updatedsubmission_abandonedrequest_completedrequest_expiredrequest_canceledidleWindowstringRequired when eventType is submission_abandoned; rejected for every other type.
12h1d3d1wsigningSecretstringOptional HMAC signing secret, 32 to 255 characters. When supplied, deliveries include X-Formstep-Signature. The secret is
stored but never returned by the API.
curl -X POST https://api.formstep.io/api/v1 \
-H "Authorization: Bearer fs_..." \
-H "Content-Type: application/json" \
-d '{
"method": "webhooks.create",
"params": {
"formId": "frm_abc123",
"targetUrl": "https://hooks.zapier.com/hooks/catch/...",
"provider": "zapier",
"eventType": "submission_created",
"signingSecret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
}'{
"ok": true,
"data": {
"subscriptionId": "int_new123",
"formId": "frm_abc123",
"provider": "zapier",
"targetUrl": "https://hooks.zapier.com/hooks/catch/...",
"eventType": "submission_created"
}
}Abandoned-submission subscriptions return the selected idleWindow from both webhooks.create and
webhooks.list. Every other subscription omits it.
A request subscription hears the same events a callback does, but as its own delivery: its own event
id, its own signature, and its own five-attempt retry budget, after which the subscription pauses. A request created with a
callbackUrl on a form with a request_completed subscription therefore fires twice, once to each receiver.
requests.replayCallback re-sends the callback only. Use requests.sample to see the payload
before any request has ended.
webhooks.delete
Remove a webhook subscription.
subscriptionIdstringRequiredSubscription ID from webhooks.list or webhooks.create.
{
"ok": true,
"data": {
"subscriptionId": "int_abc123",
"deleted": true
}
}Analytics
analytics.get
Get aggregated analytics metrics for a form. Supports date range, device, traffic source, and country filters.
Analytics are a Pro feature, and the rule follows the workspace owner’s plan, the same way the dashboard’s Analytics tab does. If the owner is not on Pro, this returns UPGRADE_REQUIRED, including for history recorded while they were. A free member of a Pro owner’s workspace gets the data.
formIdstringRequiredForm ID.
fromnumberStart of date range as Unix timestamp in milliseconds. Must be less than or equal to to when both are set.
tonumberEnd of date range as Unix timestamp in milliseconds. Omit both for all time; period then comes back as
{ "from": null, "to": null }.
devicestringDefault allFilter by device type.
alldesktopmobiletablettrafficSourcestringFilter by traffic source (e.g. "Direct", "Google").
countrystringFilter by 2-letter country code (e.g. "US", "DE").
includeEventsbooleanDefault falseAlso return the sanitized analytics events behind the metrics, for your own analysis. No visitor ids.
Rates are numbers from 0 to 100, counts are integers, and totalEvents is the raw event row count before deduplication into
unique visitors.
{
"ok": true,
"data": {
"formId": "frm_abc123",
"period": { "from": null, "to": null },
"totalEvents": 17,
"metrics": {
"views": 7,
"uniqueVisitors": 7,
"engaged": 6,
"submissions": 4,
"engagementRate": 86,
"completionRate": 57,
"bounceRate": 14
},
"breakdown": {
"byBrowser": { "Chrome": 7 },
"byCountry": { "US": 4, "DE": 3 },
"byDevice": { "desktop": 6, "mobile": 1 },
"bySource": { "Direct": 5, "Google": 2 }
}
}
}Workspaces
workspaces.list
List the workspaces your token can reach. No parameters.
An API token is bound to one workspace, so this returns exactly that one, even when your account belongs to several.
{
"ok": true,
"data": {
"items": [
{
"id": "ws_abc123",
"name": "Acme Inc",
"createdAt": 1714041800000,
"role": "owner"
}
],
"nextCursor": null,
"hasMore": false,
"canPaginate": false
}
}workspaces.createInvite
Create an invite link for a workspace. Invite methods take an API token from the workspace owner; an OAuth app gets 403 FORBIDDEN with reason API_KEY_REQUIRED, whatever its scopes.
workspaceIdstringRequiredWorkspace ID.
expiresAtnumberExpiration as a future Unix timestamp in milliseconds.
maxUsesnumberMaximum number of times the invite can be used.
{
"ok": true,
"data": {
"id": "inv_abc123",
"workspaceId": "ws_abc123",
"code": "aBcDeFgH",
"expiresAt": null,
"maxUses": null,
"uses": 0,
"createdAt": 1714041851000
}
}workspaces.getInvite
Get one workspace invite. Returns the same shape as workspaces.createInvite.
inviteIdstringRequiredInvite ID.
workspaces.updateInvite
Update an existing workspace invite. Provide at least one of expiresAt or maxUses, or the call is rejected.
Returns the updated invite.
inviteIdstringRequiredInvite ID.
expiresAtnumberNew expiration timestamp in milliseconds.
maxUsesnumberNew max uses limit.
workspaces.revokeInvite
Permanently revoke a workspace invite.
inviteIdstringRequiredInvite ID.
{
"ok": true,
"data": {
"inviteId": "inv_abc123",
"revoked": true
}
}Folders
folders.list
List folders in a workspace.
workspaceIdstringRequiredWorkspace ID.
limitnumberDefault 20Page size (1 to 100).
cursorstringPagination cursor.
{
"ok": true,
"data": {
"items": [
{
"id": "fld_abc123",
"name": "Customer Feedback",
"workspaceId": "ws_abc123",
"parentId": null,
"createdAt": 1714041800000
}
],
"nextCursor": null,
"hasMore": false,
"canPaginate": false
}
}folders.create
Create a folder in a workspace. Idempotent: returns the existing folder if a folder with the same name already exists.
workspaceIdstringRequiredWorkspace ID.
namestringRequiredFolder name (1 to 255 characters).
parentIdstring | nullParent folder ID for nesting. Omit for root level.
{
"ok": true,
"data": {
"id": "fld_new123",
"name": "Customer Feedback",
"workspaceId": "ws_abc123",
"parentId": null,
"createdAt": 1714041851000,
"alreadyExisted": false
}
}folders.update
Rename a folder or move it to a different parent.
folderIdstringRequiredFolder ID.
namestringNew folder name (1 to 255 characters).
parentIdstring | nullNew parent folder. Pass null to move to root.
folders.delete
Delete a folder and all its subfolders. The forms inside move to the Trash, where they can be restored for 30 days.
folderIdstringRequiredFolder ID.
Destructive operation
The folder and its subfolders are deleted for good. Their forms are not: live forms move to the Trash and come back out with
forms.restore; forms already in the Trash stay there. trashedFormIds lists the forms that moved;
deletedFormIds repeats it for older callers and is deprecated.
{
"ok": true,
"data": {
"folderId": "fld_abc123",
"deletedFolderIds": ["fld_abc123"],
"trashedFormIds": ["frm_in_folder"],
"deletedFormIds": ["frm_in_folder"],
"message": "Folder and 0 subfolder(s) deleted; 1 form(s) moved to the Trash."
}
}Translations
translations.listLanguages
List all languages configured on a form.
formIdstringRequiredForm ID.
{
"ok": true,
"data": {
"formId": "frm_abc123",
"items": [
{
"language": "es",
"completion": { "total": 15, "current": 12, "outdated": 2, "missing": 1, "suggested": 0 },
"lastUpdatedAt": 1714041851000
}
],
"nextCursor": null,
"hasMore": false,
"canPaginate": false
}
}translations.addLanguage
Register a language on a form. Every other translation method fails with 404 NOT_FOUND until you do.
formIdstringRequiredForm ID.
languagestringRequiredBCP-47 language tag (e.g. "es", "pt-BR").
{
"ok": true,
"data": {
"formId": "frm_abc123",
"language": "es",
"rowId": "tl_new123"
}
}translations.removeLanguage
Remove a language and all its translations from a form.
formIdstringRequiredForm ID.
languagestringRequiredBCP-47 language tag.
translations.listEntries
List every source key for one language on a form, with its current state. This is how you discover the key values translations.setEntry
takes.
formIdstringRequiredForm ID.
languagestringRequiredBCP-47 language tag. Must already be on the form.
{
"ok": true,
"data": {
"formId": "frm_abc123",
"language": "es",
"completion": { "total": 15, "current": 12, "outdated": 2, "missing": 1, "suggested": 0 },
"items": [
{
"key": "block_q_1.title",
"status": "current",
"value": "[{\"text\":\"Tu correo electrónico\"}]",
"sourceFragment": "[{\"text\":\"Your email\"}]",
"updatedAt": 1714041851000,
"block": { "id": "q_1", "type": "email-input", "label": "Your email" }
}
],
"hasMore": false
}
}status is missing (nothing stored), outdated (the source changed since), current, or
suggested (an AI suggestion staged but not accepted). Keys cover form content (block_<id>.*) and, once an
author has customized them, the respondent confirmation and reminder emails (email.confirmation.*,
email.reminder.*).
translations.setEntry
Set a single translation entry. The language must have been added via translations.addLanguage first.
formIdstringRequiredForm ID.
languagestringRequiredBCP-47 language tag.
keystringRequiredA key from translations.listEntries. Do not construct one by hand.
valuestringRequiredThe translated fragment, JSON-stringified. Its mark structure must match the source fragment.
curl -X POST https://api.formstep.io/api/v1 \
-H "Authorization: Bearer fs_..." \
-H "Content-Type: application/json" \
-d '{
"method": "translations.setEntry",
"params": {
"formId": "frm_abc123",
"language": "es",
"key": "block_q_1.title",
"value": "[{\"text\":\"Tu correo electrónico\"}]"
}
}'Writes here are live
The API has no draft-then-publish step: a setEntry or deleteEntry reaches respondents immediately. The
dashboard and the MCP translation tools use a draft instead.
Returns { formId, language, key }.
translations.deleteEntry
Delete a single translation entry, reverting that key to the form’s default language. Idempotent. When the last entry for a language goes, the language drops off the form’s published languages.
formIdstringRequiredForm ID.
languagestringRequiredBCP-47 language tag.
keystringRequiredTranslation key to delete.
Account
me.get
Get information about the authenticated user.
{
"ok": true,
"data": {
"id": "usr_abc123",
"email": "you@example.com",
"name": "Jane Doe"
}
}Meta
methods.list
List every method name this deployment serves, sorted. The authoritative answer when this page and the server disagree.
{
"ok": true,
"data": {
"methods": [
"methods.list",
"analytics.get",
"folders.create",
"folders.delete",
"folders.list",
"folders.update",
"formSettings.get",
"formSettings.update",
"forms.create",
"forms.delete",
"forms.get",
"forms.list",
"..."
]
}
}Error reference
Every error response has the same shape. code says what kind of failure it is, and details.reason says what caused it. The set of
codes is closed: a new failure adds a reason, never a code. Branch on code, and read reason to know what to fix.
{
"ok": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "No field with key \"company\" on this form.",
"details": { "reason": "UNKNOWN_FIELD_KEY", "field": "prefill.company", "validKeys": ["company_name", "contacts"] }
}
}details is there whenever the server can name the cause. Besides reason, it may carry field (the
parameter at fault, dotted for nesting), validKeys, validValues (the values a parameter accepts),
expectedType, feature (on UPGRADE_REQUIRED), and retryAfterMs (on a throttled call).
Codes
| Code | Status | What it means |
|---|---|---|
| VALIDATION_ERROR | 400 | Invalid or missing parameters, or a body over 1 MiB. |
| UNAUTHORIZED | 401 | Missing or invalid API token. |
| UPGRADE_REQUIRED | 402 | The plan does not include the feature, or a limit of the plan is spent. The reason says which. |
| FORBIDDEN | 403 | The token cannot reach the resource, such as a form in another workspace. |
| NOT_FOUND | 404 | The resource does not exist. |
| METHOD_NOT_FOUND | 404 | Unknown method name. methods.list lists the ones there are. |
| CONFLICT | 409 | The resource is not in a state that allows the call, such as a request that is no longer pending, or an idempotency key reused with a different body. |
| RATE_LIMITED | 429 | Too many calls: over a token's rate limit, over an email limit, or too many failed authentications from this IP. Retry after retryAfterMs. |
| INTERNAL_ERROR | 500 | Unexpected server error. Try again later. |
Reasons when you create a request
These come from requests.create. Each is a VALIDATION_ERROR unless the row names another code. An
inline form adds its own INLINE_FORM_* reasons, listed under
When the call is refused.
| Reason | What to do |
|---|---|
| FORM_TRASHED | Restore the form from the trash. A form in the trash takes no new requests. |
| FORM_NOT_PUBLISHED | Publish the form. A request pins a published version, so there has to be one. |
| FORM_CLOSED | Reopen the form in Settings, Submissions tab, Accepting submissions. A closed form takes no new requests. |
| UNKNOWN_FIELD_KEY | No field has that key, or it belongs in another parameter: every context key must be a hidden field on the published form, and free-form keys go in metadata. validKeys lists the accepted keys. A key that used to work was changed or deleted in the editor. |
| CONTEXT_KEY_NOT_HIDDEN_FIELD | A visible question or a calculated field was sent in context. Visible questions go in prefill; calculated fields take nothing. |
| INVALID_PREFILL_VALUE | Wrong shape for the question type; expectedType says what was wanted. Send the option key, not the label, and { "row_key": "column_key" } for a matrix. expectedType "not_prefillable" means the field takes no value from you: a file, signature, payment, appointment or calculated field. |
| READONLY_REQUIRES_PREFILL | A locked key has no value. Every key in readonly must also be in prefill. |
| READONLY_REQUIRED_EMPTY | A required field is locked with an empty value, so the recipient could never submit. Send a value or stop locking it. |
| LANGUAGE_NOT_PUBLISHED | That language is not published on the current version. validKeys lists the ones that are. |
| INVALID_REMINDER_SCHEDULE | An offset could not be read or is under one hour, an offset appears twice, or there are more than five. Use whole days, hours or minutes: 2d, 12h, 90m. |
| EXPIRY_OUT_OF_RANGE | expiresAt is in the past or more than 365 days out. |
| CALLBACK_URL_NOT_ALLOWED | The URL is not HTTPS, carries credentials, or resolves to a private address. Localhost does not work, so use a tunnel. |
| DOMAIN_NOT_ALLOWED | That custom domain is not active, or belongs to another workspace. |
| RECIPIENT_EMAIL_REQUIRED | Email delivery and a reminder schedule need recipient.email. |
| RECIPIENT_NAME_TOO_LONG | recipient.name is over 100 characters. The invitation greets the recipient by it, so send their name only. |
| IDEMPOTENCY_CONFLICT | CONFLICT. That key was used with a different body. Use a new key, or send the original body again unchanged. |
| UPGRADE_REQUIRED | UPGRADE_REQUIRED. The plan does not include the feature: invitations and reminders need Pro or Business. |
| FREE_INVITATIONS_USED | UPGRADE_REQUIRED. This Free account has spent its free invitations. Create the request with "delivery": "none" and send the link yourself, or upgrade. |
| FREE_INVITATIONS_OWN_ADDRESS_ONLY | UPGRADE_REQUIRED. On Free, invitations go only to your own address, which validValues names. Send the link yourself, or upgrade. |
| MONTHLY_ALLOWANCE_REACHED | UPGRADE_REQUIRED. The workspace has spent this month's allowance. Requests already created can still be answered; new ones wait for the 1st of the month (UTC) or an upgrade. |
| TEST_REQUEST_LIMIT_REACHED | RATE_LIMITED. A Free workspace has created its 10 test requests for today. |
| RECIPIENT_EMAIL_LIMITED | RATE_LIMITED. This address has had the most emails one form may send it in a day. Retry after retryAfterMs. |
| INVITATION_RATE_LIMITED | RATE_LIMITED. The account has sent its hourly share of invitations and reminders. Retry after retryAfterMs. |
Documents
From documents.create and from the documents parameter of requests.create. Each is a
VALIDATION_ERROR except DOCUMENT_NOT_FOUND, which is NOT_FOUND.
| Reason | What to do |
|---|---|
| DOCUMENT_TYPE_NOT_ALLOWED | Send a PDF or an image. Office documents are not accepted. |
| DOCUMENT_TOO_LARGE | One document may be 25 MB. |
| INVALID_DOCUMENT_NAME | The name must be 1 to 200 characters. |
| INVALID_DOCUMENT_SHA256 | sha256 must be 64 hex characters. |
| DOCUMENT_NOT_FOUND | No upload with that documentId in this workspace, or it aged out unused. |
| DOCUMENT_NOT_UPLOADED | The upload was reserved, but the bytes never arrived. PUT the file to its uploadUrl first. |
| DOCUMENT_INVALID | The uploaded bytes disagree with the size, type or sha256 you declared, or the file is not a PDF or an image. |
| INVALID_DOCUMENT_TARGET | The form has no Documents block, or it has several and field does not name one. validKeys lists the block keys. |
| DOCUMENTS_TOO_MANY | The block would show more than 20 documents, counting the ones the form already has. |
| DOCUMENTS_TOO_LARGE | One request may carry 100 MB of documents in total. |
Reasons on an existing request
From requests.get, requests.list, requests.cancel, requests.delete,
requests.remind and requests.replayCallback. Each is a CONFLICT unless the row names another code.
| Reason | What to do |
|---|---|
| REQUEST_NOT_FOUND | NOT_FOUND. No request with that id in a workspace this token can reach, or it was deleted. |
| SCOPE_REQUIRED | VALIDATION_ERROR. requests.list needs a workspaceId or a formId. |
| API_KEY_REQUIRED | FORBIDDEN. requests.delete takes an API token. An OAuth app, such as an agent connected over MCP, cannot delete. |
| REQUEST_NOT_PENDING | The request is already completed, expired or canceled. A finished request cannot be reminded or canceled. |
| REQUEST_EXPIRED | The expiry has passed, but the status has not caught up yet. A reminder would point at a link that no longer opens. |
| REMINDER_TOO_SOON | A manual reminder went out less than 10 minutes ago. Wait retryAfterMs. |
| REMINDER_CAP_REACHED | The request has had all 8 reminders it will ever get, manual and scheduled together. |
| TEST_REQUEST | Nothing is ever emailed for a test request. Open its link yourself instead. |
| REQUEST_NOT_TERMINAL | The request is still pending, so there is no callback to replay yet. |
| NO_CALLBACK_TO_REPLAY | The request was created without a callbackUrl. |