# API reference

Every REST API method with its parameters, examples and responses: forms, submissions, share links, requests, webhooks and more. Ends with every error code and reason.

POST `https://api.formstep.io/api/v1`

> **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](https://formstep.io/docs/developers/overview/) for authentication, and [API tokens](https://formstep.io/docs/developers/api-tokens/) for the token itself.

The same reference is available as an [OpenAPI 3.1 description](https://formstep.io/docs/openapi.json) for code generators, API clients, and agents.

## Methods

[Forms](#forms)

- [forms.list](#forms-list)
- [forms.get](#forms-get)
- [forms.create](#forms-create)
- [forms.update](#forms-update)
- [forms.publish](#forms-publish)
- [forms.unpublish](#forms-unpublish)
- [forms.delete](#forms-delete)
- [forms.restore](#forms-restore)
- [formSettings.get](#form-settings-get)
- [formSettings.update](#form-settings-update)

[Submissions](#submissions)

- [submissions.list](#submissions-list)
- [submissions.pdf](#submissions-pdf)
- [submissions.sample](#submissions-sample)

[Share links](#share-links)

- [shareLinks.list](#share-links-list)
- [shareLinks.create](#share-links-create)
- [shareLinks.update](#share-links-update)

[Fields](#fields)

- [fields.list](#fields-list)

[Requests](#requests)

- [requests.create](#requests-create)
- [requests.get](#requests-get)
- [requests.list](#requests-list)
- [requests.cancel](#requests-cancel)
- [requests.delete](#requests-delete)
- [requests.remind](#requests-remind)
- [requests.replayCallback](#requests-replay-callback)
- [requests.sample](#requests-sample)
- [documents.create](#documents-create)

[Connections](#connections)

- [connections.list](#connections-list)
- [scheduler.listEventTypes](#scheduler-list-event-types)
- [paymentConnections.setDefault](#payment-connections-set-default)

[Webhooks](#webhooks)

- [webhooks.list](#webhooks-list)
- [webhooks.create](#webhooks-create)
- [webhooks.delete](#webhooks-delete)

[Analytics](#analytics)

- [analytics.get](#analytics-get)

[Workspaces](#workspaces)

- [workspaces.list](#workspaces-list)
- [workspaces.createInvite](#workspaces-create-invite)
- [workspaces.getInvite](#workspaces-get-invite)
- [workspaces.updateInvite](#workspaces-update-invite)
- [workspaces.revokeInvite](#workspaces-revoke-invite)

[Folders](#folders)

- [folders.list](#folders-list)
- [folders.create](#folders-create)
- [folders.update](#folders-update)
- [folders.delete](#folders-delete)

[Translations](#translations)

- [translations.listLanguages](#translations-list-languages)
- [translations.addLanguage](#translations-add-language)
- [translations.removeLanguage](#translations-remove-language)
- [translations.listEntries](#translations-list-entries)
- [translations.setEntry](#translations-set-entry)
- [translations.deleteEntry](#translations-delete-entry)

[Account](#me)

- [me.get](#me-get)

[Meta](#meta)

- [methods.list](#methods-list)

## Conventions

- `params` may be omitted; it defaults to `{}`. An unknown method is `404 METHOD_NOT_FOUND`.

- A token is bound to one workspace. Naming another workspace, or a form in one, is `403 FORBIDDEN` even when you belong to both.

- List methods paginate. They return `{ items, nextCursor, hasMore }`; most also return `canPaginate`, which is `false` when `hasMore` is true but no cursor can resume (fuzzy search). Pass `nextCursor` back as `cursor`. `limit` is 1 to 100, default 20, except for `requests.list`, whose default is 25.

- The rate limit is 120 calls per minute per token, shared with the [MCP server](https://formstep.io/docs/developers/mcp-server/); `requests.create` and `documents.create` also share 60 per minute. Failed authentication is limited separately, 30 per 15 minutes per IP, after which bad tokens see `RATE_LIMITED` instead of `UNAUTHORIZED`.

- 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.

**Parameters**

`workspaceId` string Required

Workspace ID.

`folderId` string | null

Filter by folder. Pass `null` for root-level forms only. Omit to list all.

`query` string

Fuzzy name search. Results capped at `limit`; not cursor-paginated.

`limit` number Default `20`

Page size (1 to 100).

`cursor` string

Pagination cursor from a previous response.

**Example request**

```bash
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" }
  }'
```

**Example response**

200 Success

```json
{
  "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
  }
}
```

400 Missing workspaceId

401 Invalid or missing API token

429 Rate limit exceeded

### forms.get

Get full details for a single form, including questions, cover, logo, and a preview URL.

**Parameters**

`formId` string Required

Form ID.

**Example request**

```bash
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" }
  }'
```

**Example response**

200 Success

```json
{
  "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..."
  }
}
```

400 Missing formId

404 Form not found

### forms.create

Create a new empty form. Returns the form and a preview URL.

**Parameters**

`name` string Required

Form name (1 to 255 characters).

`workspaceId` string Required

Workspace ID.

`folderId` string

Place the form in a folder. Omit to create at workspace root.

**Example request**

**cURL**

```bash
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"
    }
  }'
```

**JavaScript**

```ts
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()
```

**Python**

```python
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()
```

**Example response**

200 Form created

```json
{
  "ok": true,
  "data": {
    "id": "frm_new123",
    "name": "Contact",
    "workspaceId": "ws_abc123",
    "folderId": null,
    "isPublished": false,
    "createdAt": 1714041851000,
    "previewUrl": "https://formstep.io/preview/abc..."
  }
}
```

400 Missing name or workspaceId

401 Invalid or missing API token

### forms.update

Update form metadata: name, folder, emoji, cover, or logo. Does not update form content (use the MCP editor tools for that).

**Parameters**

`formId` string Required

Form ID.

`name` string

New form name (1 to 255 characters).

`folderId` string | null

Move form to a folder. Pass `null` to move to workspace root.

`emoji` string | null

Form emoji (max 10 characters). Pass `null` to clear.

`cover` object

Cover. `{"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.

`logo` object

Logo. `{"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.

**Example request**

```bash
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": "📋"
    }
  }'
```

**Example response**

200 Form updated

```json
{
  "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](https://formstep.io/docs/developers/requests-api/#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.

**Parameters**

`formId` string Required

Form 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](#share-links-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`.

**Parameters**

`formId` string Required

Form ID.

### forms.delete

Move a form to trash. Its active share links are revoked, so their public URLs stop serving.

**Parameters**

`formId` string Required

Form 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.

**Parameters**

`formId` string Required

Form ID.

`folderId` string | null

Where 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.

**Parameters**

`formId` string Required

Form 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.

**Parameters**

`formId` string Required

Form ID.

**Access**

`language` (BCP-47, default `"en"`), `requireAuthentication`, `showBranding`, `captchaEnabled`, `passwordEnabled`, `password` (4 characters or more; a string implies `passwordEnabled: true`, `null` clears the gate).

**Owner notifications**

`notifyOnSubmission`, `notificationEmails` (array), `selfNotificationSubject`, `selfNotificationEmailBody`, `pdfGenerationEnabled`, `showViewSubmissionButton`. Owner emails are not translatable, so write them in the language you want.

**Respondent notifications**

`respondentNotificationEnabled`, `respondentNotificationTo` (the field id of an email question, or `null`), `respondentNotificationSubject`, `respondentNotificationBody`, `respondentNotificationPdfEnabled`.

**Reminders**

`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.

**After submit**

`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).

**Retention**

`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.

`emailDomainId` string | null

A 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.

**Parameters**

`formId` string Required

Form ID.

`includeDrafts` boolean Default `false`

Include responses that were started but never submitted. Drafts are a Pro feature: on Free only completed submissions are listed.

`translationLanguage` string

Attach stored AI translations of the answers under `items[].translation.display`, keyed like `display`. `items[].answers` and `items[].display` always stay the original.

`limit` number Default `20`

Page size (1 to 100).

`cursor` string

Pagination cursor from a previous response.

**Example response**

200 Success

```json
{
  "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](https://formstep.io/docs/developers/requests-api/#field-keys) and `display` with the same keys as readable text. A [webhook payload](https://formstep.io/docs/developers/webhooks-reference/), a [request callback](https://formstep.io/docs/developers/callbacks/) 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.

**Parameters**

`formId` string Required

Form ID.

`submissionId` string Required

Submission ID. Must belong to that form and be completed.

**Example response**

200 Success

```json
{
  "ok": true,
  "data": {
    "url": "https://api.formstep.io/api/storage/...",
    "filename": "formstep-submission-sub_xyz789.pdf",
    "contentType": "application/pdf",
    "byteLength": 148213
  }
}
```

404 No retained PDF for a Zapier integration on this submission

### 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](#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.

**Parameters**

`formId` string Required

Form ID.

**Example response**

200 Sample generated

```json
{
  "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](https://formstep.io/docs/developers/webhooks-reference/#payload) explains each field of the payload.

## Share links

### shareLinks.list

List share links for a form.

**Parameters**

`formId` string Required

Form ID.

`includeRevoked` boolean

Include revoked links in the result.

`limit` number Default `20`

Page size (1 to 100).

`cursor` string

Pagination cursor.

**Example response**

200 Success

```json
{
  "ok": true,
  "data": {
    "items": [
      {
        "id": "sl_abc123",
        "code": "RPjNes52",
        "url": "https://formstep.io/RPjNes52",
        "customDomainUrl": null,
        "formId": "frm_abc123",
        "createdAt": 1714041851000,
        "expiresAt": null,
        "maxClaims": null,
        "claimedCount": 7,
        "isRevoked": false,
        "revokedAt": null,
        "customDomainId": null,
        "customSlug": null
      }
    ],
    "availableCustomDomains": [],
    "nextCursor": null,
    "hasMore": false,
    "canPaginate": false
  }
}
```

### shareLinks.create

Create a share link for a form. The form must be published first.

**Parameters**

`formId` string Required

Form ID. A form that is unpublished, or was published and then unpublished, is rejected; call `forms.publish` first.

`expiresAt` number

Expiration as a future Unix timestamp in milliseconds. Unlike on update, `0` is not accepted here.

`maxClaims` number

Maximum number of times this link can be used. Must be positive; use `shareLinks.update` to clear it later.

The response is the share link (same shape as a `shareLinks.list` item) plus `availableCustomDomains`, so you can follow up with `shareLinks.update` to attach one.

**Example request**

```bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer fs_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "shareLinks.create",
    "params": {
      "formId": "frm_abc123",
      "maxClaims": 100
    }
  }'
```

### shareLinks.update

Update a share link. Can change expiration, max claims, custom domain, slug, or revoke the link.

**Parameters**

`shareLinkId` string Required

Share link ID.

`expiresAt` number

New expiration timestamp in milliseconds. Pass `0` to remove expiration.

`maxClaims` number

New max claims. Pass `-1` to remove the limit.

`customDomainId` string | null

Attach a custom domain. Pass `null` to detach.

`customSlug` string | null

Custom URL slug (3 to 64 characters, lowercase alphanumeric and hyphens). Required together with `customDomainId`; pass both `null` to detach. `login`, `auth-callback`, `preview`, `payment`, `api`, `admin` and `health` are reserved.

`revoked` boolean

Set to `true` to permanently revoke the link. Cannot be combined with other fields, and cannot be undone. This is the only delete path for a share link.

## 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](https://formstep.io/docs/developers/requests-api/#discover-fields) for what each flag means.

**Parameters**

`formId` string Required

Form ID. A form that has never been published has no field keys yet and answers `published: false` with no items.

**Example request**

```bash
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..." }
  }'
```

**Example response**

200 Success

```json
{
  "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.

400 Missing formId

404 Form not found

## 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](https://formstep.io/docs/developers/requests-api/).

### requests.create

Create a request. Spends one unit of the workspace’s monthly allowance, whether or not the recipient answers.

**Parameters**

`formId` string

The published form to assign. Send this or `form`, not both.

`form` object

Build 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](https://formstep.io/docs/developers/inline-forms/).

`workspaceId` string

Where an inline form goes. Defaults to the token’s workspace; any other is refused.

`recipient` object

`{ email?, name? }`. An email is required when `delivery` is `"email"`; otherwise it only identifies the person in the Requests page and on their answers.

`prefill` object

Initial answers by field key. The recipient sees them and can change them.

`readonly` string\[]

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.

`context` object

Values 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`.

`metadata` object

Your own bookkeeping. Never reaches the form; comes back in callbacks and reads.

`language` string

One of the form’s published languages. Defaults to the form’s own default.

`delivery` string Default `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.

`reminders` string\[]

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](https://formstep.io/docs/developers/requests-api/#reminders).

`expiresAt` number

Epoch milliseconds. Defaults to 30 days out; 365 days is the maximum.

`callbackUrl` string

Where Formstep POSTs the callback when the request ends. HTTPS only, and the host must resolve to a public address.

`externalId` string

Your own id for this request. Filterable in `requests.list`.

`idempotencyKey` string

Repeating it with the same body returns the original request with `deduplicated: true`. A different body is rejected. Keys live 30 days.

`domainId` string

Mint the link on one of your custom domains.

`documents` object\[]

`[{ documentId, field?, name? }]`: files handed to this one recipient, uploaded first with [documents.create](#documents-create).

`test` boolean Default `false`

A 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.

**Example request**

```bash
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"
    }
  }'
```

**Example response**

200 Success

```json
{
  "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](https://formstep.io/docs/developers/requests-api/#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](https://formstep.io/docs/building-forms/ai-validation/) 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.

400 Unknown field key, wrong value shape, or a locked key that is not prefilled

400 Form not published (FORM\_NOT\_PUBLISHED) or closed (FORM\_CLOSED), or callbackUrl not allowed (CALLBACK\_URL\_NOT\_ALLOWED)

400 An inline form refused: a block fails its schema, Stripe or Cal.com is not connected, or publishing refused it (INLINE\_FORM\_…)

402 Monthly allowance spent (MONTHLY\_ALLOWANCE\_REACHED), Free invitations spent (FREE\_INVITATIONS\_USED) or sent to another address (FREE\_INVITATIONS\_OWN\_ADDRESS\_ONLY), or reminders below Pro

404 Form not found

409 Idempotency key reused with a different body (IDEMPOTENCY\_CONFLICT)

429 More than 60 requests.create calls in a minute on this token, or a Free workspace's 11th test request in a day (TEST\_REQUEST\_LIMIT\_REACHED)

### 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.

**Parameters**

`requestId` string Required

Request ID.

**Example response**

200 Success

```json
{
  "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](https://formstep.io/docs/developers/requests-api/#outcome). 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](https://formstep.io/docs/submissions-analytics/submission-retention/#retention-and-requests) 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](https://formstep.io/docs/submissions-analytics/submission-retention/#unanswered-requests) 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.

404 Request not found (REQUEST\_NOT\_FOUND)

### 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.

**Parameters**

`workspaceId` string

Scope to a workspace. Give this or `formId`.

`formId` string

Scope to one form.

`status` string

`pending`, `completed`, `expired`, or `canceled`.

`outcome` string

`approve`, `decline`, or `changes`. Implies completed requests only.

`externalId` string

Your own id, to find the request a run created.

`includeTest` boolean Default `false`

Include requests created with `test: true`.

`limit` number Default `25`

Page size (1 to 100).

`cursor` string

Pagination cursor from a previous response.

**Example response (trimmed)**

200 Success

```json
{
  "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.

**Parameters**

`requestId` string Required

Request ID.

`reason` string

Your note for why, kept on the request and sent in the callback.

200 The canceled request

409 Already completed, expired, or canceled (REQUEST\_NOT\_PENDING)

### 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](https://formstep.io/docs/developers/api-tokens/); an OAuth app, such as an AI agent connected over MCP, gets `403 FORBIDDEN` with reason `API_KEY_REQUIRED`, whatever its scopes.

**Parameters**

`requestId` string Required

Request ID.

When the request built its form (an [inline form](https://formstep.io/docs/developers/inline-forms/)) and no other request uses that form, the form is deleted too and `formDeleted` is `true`. Deleting cannot be undone.

200 { id, deleted: true, formDeleted }

403 Called with an OAuth app's token (API\_KEY\_REQUIRED)

404 No such request in a workspace you can reach (REQUEST\_NOT\_FOUND)

### requests.remind

Email the recipient now, without touching the reminder schedule. Needs a recipient email and a Pro or Business plan.

**Parameters**

`requestId` string Required

Request 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.

200 The request, with remindersSent incremented

400 No recipient email on the request (RECIPIENT\_EMAIL\_REQUIRED)

402 Request reminders require Pro or Business (UPGRADE\_REQUIRED)

409 Not pending (REQUEST\_NOT\_PENDING), too soon (REMINDER\_TOO\_SOON, with details.retryAfterMs), cap reached (REMINDER\_CAP\_REACHED), or a test request (TEST\_REQUEST)

### 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.

**Parameters**

`requestId` string Required

Request ID. Must be completed, expired, or canceled.

**Example response**

200 Success

```json
{
  "ok": true,
  "data": { "dispatchId": "kf9...", "eventId": "evt_kf9..." }
}
```

409 Still pending, so there is no terminal callback to replay (REQUEST\_NOT\_TERMINAL)

409 The request was created without a callbackUrl (NO\_CALLBACK\_TO\_REPLAY)

### 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](#webhooks-create) receives, so connectors use it for field discovery. A completed sample carries the same example answers [submissions.sample](#submissions-sample) shows; an expired or canceled sample carries the request block alone.

**Parameters**

`formId` string Required

Form ID.

`eventType` string Required

Which ending to sample, in the `webhooks.create` spelling. The envelope’s `type` is the dotted form.

One of `request_completed` `request_expired` `request_canceled`

**Example response**

200 Sample generated

```json
{
  "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](https://formstep.io/docs/developers/callbacks/#payload); the submission half on the [webhooks reference](https://formstep.io/docs/developers/webhooks-reference/#payload). 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](https://formstep.io/docs/building-forms/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.

**Parameters**

`formId` string Required

The form whose Documents block will show the file. Scopes the upload to that workspace.

`name` string Required

Display name the recipient sees (1 to 200 characters). Overridable per request.

`contentType` string Required

`application/pdf` or an image type: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/avif`, `image/bmp`, `image/tiff`. Office documents are not accepted.

`size` number Required

Exact byte length. Maximum 25 MB (26,214,400).

`sha256` string

Hex digest of the bytes. Verified after upload when given.

**Example response**

200 Upload reserved

```json
{
  "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`:

```json
{
  "method": "requests.create",
  "params": {
    "formId": "j57...",
    "documents": [
      { "documentId": "kn7...", "name": "Your lease contract" },
      { "documentId": "kn8...", "field": "attachments" }
    ]
  }
}
```

- `field` is 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](#reasons-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.

**Parameters**

`workspaceId` string Required

Workspace ID.

**Example response**

200 Success

```json
{
  "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.

**Parameters**

`workspaceId` string Required

Workspace ID.

`connection` string

The 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.

400 No Cal.com connection, an expired one, or several and none named. The message says which, and where to connect.

### 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`.

**Parameters**

`workspaceId` string Required

Workspace ID.

`connection` string Required

A Stripe connection id of that workspace, from `connections.list`.

200 { connection }, the new default

404 The connection belongs to another workspace, or does not exist

## Webhooks

### webhooks.list

List webhook subscriptions for a form.

**Parameters**

`formId` string Required

Form ID.

**Example response**

200 Success

```json
{
  "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.

**Parameters**

`formId` string Required

Form ID.

`targetUrl` string Required

HTTPS URL to receive webhook payloads.

`provider` string Required

Which 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.

One of `zapier` `make` `n8n`

`eventType` string Default `submission_created`

Event 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](https://formstep.io/docs/developers/callbacks/#payload) whenever a request on the form ends that way, signed with this subscription’s secret; test requests reach no subscription.

One of `submission_created` `submission_updated` `submission_abandoned` `request_completed` `request_expired` `request_canceled`

`idleWindow` string

Required when `eventType` is `submission_abandoned`; rejected for every other type.

One of `12h` `1d` `3d` `1w`

`signingSecret` string

Optional HMAC signing secret, 32 to 255 characters. When supplied, deliveries include `X-Formstep-Signature`. The secret is stored but never returned by the API.

**Example request**

```bash
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"
    }
  }'
```

**Example response**

200 Webhook created

```json
{
  "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](https://formstep.io/docs/developers/callbacks/) 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](#requests-sample) to see the payload before any request has ended.

### webhooks.delete

Remove a webhook subscription.

**Parameters**

`subscriptionId` string Required

Subscription ID from `webhooks.list` or `webhooks.create`.

**Example response**

200 Webhook deleted

```json
{
  "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.

**Parameters**

`formId` string Required

Form ID.

`from` number

Start of date range as Unix timestamp in milliseconds. Must be less than or equal to `to` when both are set.

`to` number

End of date range as Unix timestamp in milliseconds. Omit both for all time; `period` then comes back as `{ "from": null, "to": null }`.

`device` string Default `all`

Filter by device type.

One of `all` `desktop` `mobile` `tablet`

`trafficSource` string

Filter by traffic source (e.g. `"Direct"`, `"Google"`).

`country` string

Filter by 2-letter country code (e.g. `"US"`, `"DE"`).

`includeEvents` boolean Default `false`

Also 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.

**Example response**

200 Success

```json
{
  "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.

**Parameters**

**Example response**

200 Success

```json
{
  "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](https://formstep.io/docs/developers/api-tokens/) from the workspace owner; an OAuth app gets `403 FORBIDDEN` with reason `API_KEY_REQUIRED`, whatever its scopes.

**Parameters**

`workspaceId` string Required

Workspace ID.

`expiresAt` number

Expiration as a future Unix timestamp in milliseconds.

`maxUses` number

Maximum number of times the invite can be used.

**Example response**

200 Invite created

```json
{
  "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`.

**Parameters**

`inviteId` string Required

Invite ID.

404 Invite not found

### workspaces.updateInvite

Update an existing workspace invite. Provide at least one of `expiresAt` or `maxUses`, or the call is rejected. Returns the updated invite.

**Parameters**

`inviteId` string Required

Invite ID.

`expiresAt` number

New expiration timestamp in milliseconds.

`maxUses` number

New max uses limit.

### workspaces.revokeInvite

Permanently revoke a workspace invite.

**Parameters**

`inviteId` string Required

Invite ID.

**Example response**

200 Invite revoked

```json
{
  "ok": true,
  "data": {
    "inviteId": "inv_abc123",
    "revoked": true
  }
}
```

## Folders

### folders.list

List folders in a workspace.

**Parameters**

`workspaceId` string Required

Workspace ID.

`limit` number Default `20`

Page size (1 to 100).

`cursor` string

Pagination cursor.

**Example response**

200 Success

```json
{
  "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.

**Parameters**

`workspaceId` string Required

Workspace ID.

`name` string Required

Folder name (1 to 255 characters).

`parentId` string | null

Parent folder ID for nesting. Omit for root level.

**Example response**

200 Folder created

```json
{
  "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.

**Parameters**

`folderId` string Required

Folder ID.

`name` string

New folder name (1 to 255 characters).

`parentId` string | null

New 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.

**Parameters**

`folderId` string Required

Folder 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.

**Example response**

200 Folder deleted

```json
{
  "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.

**Parameters**

`formId` string Required

Form ID.

**Example response**

200 Success

```json
{
  "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.

**Parameters**

`formId` string Required

Form ID.

`language` string Required

BCP-47 language tag (e.g. `"es"`, `"pt-BR"`).

**Example response**

200 Language added

```json
{
  "ok": true,
  "data": {
    "formId": "frm_abc123",
    "language": "es",
    "rowId": "tl_new123"
  }
}
```

### translations.removeLanguage

Remove a language and all its translations from a form.

**Parameters**

`formId` string Required

Form ID.

`language` string Required

BCP-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.

**Parameters**

`formId` string Required

Form ID.

`language` string Required

BCP-47 language tag. Must already be on the form.

**Example response**

200 Success

```json
{
  "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.

**Parameters**

`formId` string Required

Form ID.

`language` string Required

BCP-47 language tag.

`key` string Required

A key from `translations.listEntries`. Do not construct one by hand.

`value` string Required

The translated fragment, JSON-stringified. Its mark structure must match the source fragment.

**Example request**

```bash
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.

**Parameters**

`formId` string Required

Form ID.

`language` string Required

BCP-47 language tag.

`key` string Required

Translation key to delete.

## Account

### me.get

Get information about the authenticated user.

**Parameters**

**Example response**

200 Success

```json
{
  "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.

**Parameters**

**Example response**

200 Success

```json
{
  "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.

```json
{
  "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](https://formstep.io/docs/developers/overview/#rate-limits), 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](https://formstep.io/docs/developers/inline-forms/) adds its own `INLINE_FORM_*` reasons, listed under [When the call is refused](https://formstep.io/docs/developers/inline-forms/#errors).

| 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. |

## Next steps

[Create requests from code](https://formstep.io/docs/developers/requests-api/) — How the request parameters fit together.

[Webhook API reference](https://formstep.io/docs/developers/webhooks-reference/) — Payload schema and signing
