# Formstep Docs — Developers

# Developers overview

Call the Formstep REST API to send requests and manage forms, receive webhooks and request callbacks, or connect an AI agent over MCP. The endpoint, authentication, the request and response format, and rate limits.

## What you can do

- Call the REST API to send a request to one person and read the answers, or to create, publish and change forms, share links, webhooks and translations.

- Receive events: a webhook posts every submission of a form to your URL, and a request callback posts once, when a request ends.

- Connect an AI agent over MCP, so Claude, ChatGPT, Cursor and other agents can do the same through tools.

> **No code needed?**
>
> Zapier, Make and n8n connect Formstep click by click. See the [Automation guides](https://formstep.io/docs/guides/overview/).

## Pages in this section

- [Send a request with the REST API](https://formstep.io/docs/developers/quickstart-send-a-request/) — Create an API token, read a form's field keys, and create a prefilled request with curl, from any tool that can make an HTTP call.

- [Receive and verify the callback](https://formstep.io/docs/developers/quickstart-receive-the-callback/) — Run a small receiver in Node or Python, check each callback's signature with your signing secret, and read the answers Formstep pushes when a request ends.

- [Create requests from code](https://formstep.io/docs/developers/requests-api/) — List a form's field keys, create a request with prefill, locked fields and context, hand the recipient documents, and read the answers.

- [Create a form and a request in one call](https://formstep.io/docs/developers/inline-forms/) — Send the blocks with the request: Formstep builds the form, publishes it and returns the link, with no editor in between.

- [Request callbacks](https://formstep.io/docs/developers/callbacks/) — Formstep POSTs a signed event to the callbackUrl of a request when it is completed, expires or is canceled.

- [API reference](https://formstep.io/docs/developers/rest-api/) — Every REST API method with its parameters, examples and responses: forms, submissions, share links, requests, webhooks and more.

- [API tokens](https://formstep.io/docs/developers/api-tokens/) — Create, manage, and rotate tokens for the API and MCP server.

- [MCP server](https://formstep.io/docs/developers/mcp-server/) — Use Formstep from Claude, ChatGPT, Cursor and other MCP clients.

- [Webhook API reference](https://formstep.io/docs/developers/webhooks-reference/) — Payload schema, event types, signing, retries, and REST subscription endpoints.

## Endpoint

The REST API has one URL. Every call is a `POST` with a JSON body that names the method and its parameters.

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

## Authentication

Send an [API token](https://formstep.io/docs/developers/api-tokens/) as a bearer token in the `Authorization` header. A token reaches the one workspace it was created in. Create one from **OAuth and API Keys** in the workspace sidebar.

**cURL**

```bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer fs_..." \
  -H "Content-Type: application/json" \
  -d '{"method": "forms.list", "params": {}}'
```

**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.list', params: {} }),
})
const 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.list", "params": {}},
)
data = res.json()
```

> **Keep tokens on the server**
>
> Never put a token in browser code. Call the API from your backend.

## Request and response

The body has two fields: `method` and `params`. Every method and its parameters are in the [API reference](https://formstep.io/docs/developers/rest-api/).

```json
{
  "method": "forms.list",
  "params": { "workspaceId": "abc123..." }
}
```

Every response has an `ok` field. On success, the result is in `data`. On failure, `error` holds a `code`, a `message`, and usually `details.reason`, which says what to fix. The codes and reasons are listed in the [error reference](https://formstep.io/docs/developers/rest-api/#error-reference).

```json
{ "ok": true, "data": { ... } }

{ "ok": false, "error": { "code": "VALIDATION_ERROR", "message": "Field 'name' is required." } }
```

## Rate limits

- 120 calls a minute per token, shared with the [MCP server](https://formstep.io/docs/developers/mcp-server/).

- `requests.create` and `documents.create` also share a limit of 60 calls a minute per token.

A call over a limit fails with `429 RATE_LIMITED` and `details.retryAfterMs`. Wait that long, then retry.

# Send a request with the REST API

Create an API token, read a form's field keys, and create a prefilled request with curl, from any tool that can make an HTTP call.

You need a published form. This guide uses the one built in [Build a form with an AI agent](https://formstep.io/docs/guides/ai-agents/build-a-form/): company name, contact email, VAT number, a yes or no question about payment terms, and a file upload. Every method and option used here is described in the [API reference](https://formstep.io/docs/developers/rest-api/).

## 1. Create an API token

In Formstep, open **OAuth and API Keys** in the workspace sidebar and [create a token](https://formstep.io/docs/developers/api-tokens/#create). The value is shown once. Keep it in an environment variable, not in your code:

```bash
export FORMSTEP_TOKEN='fs_...'
```

A token reaches the one workspace it was created in. Every call below is a `POST` to the same URL with the token in the `Authorization` header; the body names the method and its parameters.

## 2. Find the form ID

Open the form in the editor. The form ID is the part of the address after `/forms/`:

```text
https://app.formstep.io/<workspace ID>/forms/jx75hdx8vb5hy1x85gm17nqgkn8f674g/edit
```

The commands below use this guide’s form ID. Put your own in its place.

## 3. List the field keys

A request fills in questions, and the answers come back, by [field key](https://formstep.io/docs/developers/requests-api/#field-keys). Ask the form for its keys instead of guessing them from the titles:

```bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer $FORMSTEP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"method": "fields.list", "params": {"formId": "jx75hdx8vb5hy1x85gm17nqgkn8f674g"}}'
```

The reply lists each question of the published form. Shortened:

```json
{
  "ok": true,
  "data": {
    "published": true,
    "items": [
      { "key": "company_name", "type": "text", "title": "Company name", "required": true, "prefillable": true },
      { "key": "contact_email", "type": "email", "title": "Contact email", "required": true, "prefillable": true },
      { "key": "vat_number", "type": "text", "title": "VAT number", "required": false, "prefillable": true },
      {
        "key": "do_you_accept_our_30_day_payment_terms", "type": "radio", "title": "Do you accept our 30-day payment terms?", "required": true, "prefillable": true,
        "options": [{ "key": "yes", "label": "Yes" }, { "key": "no", "label": "No" }]
      },
      { "key": "certificate_of_incorporation", "type": "file", "title": "Certificate of incorporation", "required": true, "prefillable": false }
    ],
    "hasMore": false
  }
}
```

To prefill a choice question, send the option’s `key`, here `"yes"`, not its label. A question with `prefillable: false`, like the file upload, only the recipient can answer. The other flags are explained under [fields.list](https://formstep.io/docs/developers/rest-api/#fields-list).

## 4. Create a test request

Send the request to Ada Lovelace, with the company name filled in and locked. `test: true` makes it a [test request](https://formstep.io/docs/developers/requests-api/#test-mode): it never emails anyone and counts nowhere, so you can repeat this step with a new `idempotencyKey` each time (on Free, up to 10 times a day). Its link closes within 24 hours.

```bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer $FORMSTEP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "method": "requests.create",
    "params": {
      "formId": "jx75hdx8vb5hy1x85gm17nqgkn8f674g",
      "recipient": { "email": "ada@acme.example", "name": "Ada Lovelace" },
      "prefill": { "company_name": "Analytical Engines Ltd" },
      "readonly": ["company_name"],
      "externalId": "supplier-2043",
      "idempotencyKey": "supplier-2043",
      "test": true
    }
  }'
```

- `prefill` fills in answers the recipient can still change. A key in `readonly` is [locked](https://formstep.io/docs/requests/creating-requests/#three-buckets) as well.

- `externalId` is your own id for this piece of work, like a supplier number. It comes back on every read and callback.

- `idempotencyKey` makes a retry safe. If your code sends the same call twice, Formstep returns the first request with `deduplicated: true` instead of creating a second one.

The reply carries the request ID and the link for the recipient:

```json
{
  "ok": true,
  "data": {
    "id": "m17ayhcnj9xvzkff3atek49bdd8f6872",
    "status": "pending",
    "url": "https://form.formstep.io/r/rq_...",
    "deliveryStatus": "not_requested",
    "externalId": "supplier-2043",
    "deduplicated": false,
    "createdAt": 1790539225370,
    "expiresAt": 1793131225370
  }
}
```

`deliveryStatus: "not_requested"` means Formstep sent no email; you deliver the link yourself. To have Formstep send the invitation and reminders, add `"delivery": "email"`, which needs a Pro or Business plan; a Free account can try it on its own address. If the form has **Send reminders** on, a real request with a recipient email still gets the scheduled reminders; add `"reminders": []` to send none. See [Invitations and reminders](https://formstep.io/docs/developers/requests-api/#delivery).

> **Test requests stay out of sight**
>
> The **Requests** page lists test requests only when you turn on the **Show test requests** filter. Open the `url` yourself to complete it. Drop `test` when you send the real thing.

## 5. Complete it and read the answers

Open the `url` in a browser. The company name is filled in and locked; answer the rest and submit. Then read the request back with its ID:

```bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer $FORMSTEP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"method": "requests.get", "params": {"requestId": "m17ayhcnj9xvzkff3atek49bdd8f6872"}}'
```

Once `status` is `completed`, the reply holds two maps keyed by field key. `answers` is for code: a choice answer is the option key, a file answer is a list of files with download URLs. `display` is for people: labels and file names.

```json
"answers": {
  "company_name": "Analytical Engines Ltd",
  "do_you_accept_our_30_day_payment_terms": "yes",
  "certificate_of_incorporation": [{ "name": "certificate-of-incorporation.pdf", "type": "application/pdf", "size": 635, "url": "https://api.formstep.io/api/storage/..." }]
},
"display": {
  "company_name": "Analytical Engines Ltd",
  "do_you_accept_our_30_day_payment_terms": "Yes",
  "certificate_of_incorporation": "certificate-of-incorporation.pdf"
}
```

Asking again and again until the status changes is fine for a test. In production, give the request a `callbackUrl` and Formstep calls you when it ends; the next guide sets that up.

## Next steps

[Receive and verify the callback](https://formstep.io/docs/developers/quickstart-receive-the-callback/) — Get the answers pushed to your endpoint, and check that Formstep sent them.

[Create requests from code](https://formstep.io/docs/developers/requests-api/) — Every option a request takes: context, metadata, documents, expiry.

# Receive and verify the callback

Run a small receiver in Node or Python, check each callback's signature with your signing secret, and read the answers Formstep pushes when a request ends.

You need an API token and a published form, as in [Send a request with the REST API](https://formstep.io/docs/developers/quickstart-send-a-request/), and Node or Python on your machine. What a callback carries, and how retries work, is in [Request callbacks](https://formstep.io/docs/developers/callbacks/).

## 1. Copy the signing secret

Formstep signs every callback with your workspace’s request signing secret. Open **OAuth and API Keys** in the workspace sidebar and find the **Request signing secret** card:

![The Request signing secret card: Workspace secret, masked as rqs\_ followed by asterisks, with buttons to reveal, copy and regenerate it](https://formstep.io/docs/guides/rest-api/signing-secret.png)

Click the copy button and keep the secret in an environment variable in the terminal where your receiver will run:

```bash
export FORMSTEP_SIGNING_SECRET='rqs_...'
```

> **Do not regenerate it**
>
> The third button makes a new secret, and the old one stops working at once, even for callbacks already on their way. You do not need it to follow this guide.

## 2. Write the receiver

Save the verify function from [Signing](https://formstep.io/docs/developers/webhooks-reference/#signing) next to your receiver: as `verify.mjs` for Node, since it uses `import`, or as `verify.py` for Python. The receiver reads the raw body, checks the signature, and only then parses the JSON:

**Node.js**

```javascript
import http from 'node:http'
import { verifyFormstepSignature } from './verify.mjs'
const secret = process.env.FORMSTEP_SIGNING_SECRET
http
  .createServer((req, res) => {
    const chunks = []
    req.on('data', (chunk) => chunks.push(chunk))
    req.on('end', () => {
      const rawBody = Buffer.concat(chunks).toString('utf8')
      const signature = req.headers['x-formstep-signature'] ?? ''
      if (!verifyFormstepSignature(rawBody, signature, secret)) {
        console.log('rejected: bad signature')
        res.writeHead(401).end()
        return
      }
      const event = JSON.parse(rawBody)
      console.log(event.type, event.data.request.externalId, JSON.stringify(event.data.answers))
      res.writeHead(200).end()
    })
  })
  .listen(8787)
```

**Python**

```python
import json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
from verify import verify_formstep_signature
SECRET = os.environ['FORMSTEP_SIGNING_SECRET']
class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        raw_body = self.rfile.read(int(self.headers['Content-Length']))
        signature = self.headers.get('X-Formstep-Signature', '')
        if not verify_formstep_signature(raw_body, signature, SECRET):
            print('rejected: bad signature', flush=True)
            self.send_response(401)
            self.end_headers()
            return
        event = json.loads(raw_body)
        print(event['type'], event['data']['request'].get('externalId'), event['data'].get('answers'), flush=True)
        self.send_response(200)
        self.end_headers()
HTTPServer(('', 8787), Handler).serve_forever()
```

Hash the body exactly as it arrived. Parsing it and turning it back into JSON changes the bytes, and the signature no longer matches.

## 3. Run it and give it a public URL

Start the receiver:

```bash
node receiver.mjs      # or: python3 receiver.py
```

Formstep only calls public HTTPS addresses, so `localhost` will not do. While you test, a tunnel gives your machine one.

[Cloudflare’s quick tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/)

needs no account. Run it in a second terminal:

```bash
cloudflared tunnel --url http://localhost:8787
```

It prints an address like `https://horn-cod-classics-arab.trycloudflare.com`. ngrok and similar tools work the same way. In production, use your server’s own HTTPS URL instead.

## 4. Send a request with a callback URL

Create a test request as in the [previous quickstart](https://formstep.io/docs/developers/quickstart-send-a-request/#create), and add `callbackUrl` with the tunnel address. Use your own form ID:

```bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer $FORMSTEP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "method": "requests.create",
    "params": {
      "formId": "jx75hdx8vb5hy1x85gm17nqgkn8f674g",
      "recipient": { "email": "ada@acme.example", "name": "Ada Lovelace" },
      "prefill": { "company_name": "Analytical Engines Ltd" },
      "readonly": ["company_name"],
      "externalId": "supplier-2044",
      "idempotencyKey": "supplier-2044",
      "callbackUrl": "https://horn-cod-classics-arab.trycloudflare.com/formstep",
      "test": true
    }
  }'
```

Use a new `idempotencyKey`: the same key with a different body is rejected. Open the `url` from the reply, answer the form, and submit.

## 5. See the callback arrive

A few seconds after you submit, the receiver prints the event type, your external ID and the answers. The Node receiver prints:

```text
request.completed supplier-2044 {"certificate_of_incorporation":[{"name":"certificate-of-incorporation.pdf","size":635,"type":"application/pdf","url":"https://api.formstep.io/api/storage/...",...}],"company_name":"Analytical Engines Ltd","contact_email":"ada@acme.example","do_you_accept_our_30_day_payment_terms":"yes","vat_number":"GB123456789"}
```

To see a rejection, send the receiver any POST yourself, for example `curl -X POST -d '{}' http://localhost:8787`. It has no valid signature, so the receiver prints `rejected: bad signature` and answers 401.

## Before you go live

- Answer with a 2xx within 10 seconds. A 5xx, a 408, a 429 or a timeout is retried for about four hours, and any other 4xx, like the 401 above, stops the retries. See [Retries](https://formstep.io/docs/developers/callbacks/#retries).

- Branch on `type`. The same URL also hears `request.expired` and `request.canceled`, which carry no answers.

- Handle each event once. A retry carries the same `id`, so store the ids you have handled and skip a repeat.

- Check `test`. A callback from a test request has `"test": true`. Drop `test` from `requests.create` for real recipients.

If your receiver was down for longer than the retries last, the answers are not lost. Read them with `requests.get`, or send the callback again with [requests.replayCallback](https://formstep.io/docs/developers/rest-api/#requests-replay-callback) or from the [Requests page](https://formstep.io/docs/requests/managing-requests/).

## Next steps

[Request callbacks](https://formstep.io/docs/developers/callbacks/) — The full payload, the three events, and the retry schedule.

[API reference](https://formstep.io/docs/developers/rest-api/) — Every method, with its parameters and responses.

# Create requests from code

List a form's field keys, create a request with prefill, locked fields and context, hand the recipient documents, and read the answers. The value shapes, idempotency, test requests and the limits that apply.

To try it end to end with curl first, follow [Send a request with the REST API](https://formstep.io/docs/developers/quickstart-send-a-request/). Every parameter is listed under [Requests](https://formstep.io/docs/developers/rest-api/#requests) in the API reference. What a request is, and what the recipient sees, is explained in [Requests](https://formstep.io/docs/requests/overview/).

## Copy a call from the New request sheet

![From an automation in the New request sheet, showing curl and MCP prompt, the form id and a ready-made requests.create call](https://formstep.io/docs/images/requests/request-automation.png)

From an automation: the form id and a call already filled in with this form's field keys and what you filled in.

Open a published form, click **New request** (the paper plane icon), and fill in the sheet as if you were sending it by hand. Then open the **⋯** menu at the bottom of the sheet and pick **From an automation**. It shows the form id and the same request as a curl call or an MCP prompt, with this form's real field keys. **OAuth and API Keys** in the same menu opens the page where you create the [API token](https://formstep.io/docs/developers/api-tokens/).

Only a published form that is open for submissions takes requests. A form that was never published fails with `FORM_NOT_PUBLISHED`, a closed one with `FORM_CLOSED`.

## List the fields

[fields.list](https://formstep.io/docs/developers/rest-api/#fields-list) returns every field of the form's current published version, with its key, its type and a few flags. Read the keys from it instead of guessing them from question titles. The flags tell you where a value goes:

| The field shows | What you send |
| - | - |
| prefillable: true | A value in prefill, in the shape its type asks for (see value shapes below). |
| options, or rows and columns | The option key, not the label. The label is there so you can match the choice you know to its key. |
| context: true | A hidden field. Its value goes in context, never in prefill; in prefill it fails with UNKNOWN\_FIELD\_KEY. |
| calculated: true | Nothing. The form works the value out, and you read it back under its key in answers. |
| prefillable: false (and no other flag) | Nothing. File upload, signature, payment, appointment booking and Documents blocks are for the recipient. |
| type: "group", repeating: true | A repeating group. Its members are listed under members, and you send a list of items. |

### Field keys

A field key is the name your code uses for one field, such as `company_name`. Authors see and edit keys in the editor's **Fields** tab; see [Field keys](https://formstep.io/docs/requests/field-keys/). Until someone edits it, a key comes from the question title: lowercase, accents removed, every space or symbol turned into an underscore, up to 64 characters.

| Question title | Key |
| - | - |
| Company name | company\_name |
| What is your VAT number? | what\_is\_your\_vat\_number |
| Prénom | prenom |
| Søknad om støtte | soknad\_om\_stotte |
| الاسم الكامل | field\_3: no Latin letters or digits, so the key is field\_ plus its position |

- When two fields would get the same key, the later one gets `_2`, `_3` and so on.

- A key set by hand uses letters, digits, `_`, `.` and `-`, up to 64 characters. Keys are case-sensitive and unique within the form.

- From the first publish, a field keeps its key when the question is renamed, moved or given another type. A new type may need a new value shape. A duplicated question gets a new key.

- A key disappears when someone types a new one, or deletes a question and adds another in its place. Callbacks then arrive without that answer, and `requests.create` refuses the old key with `UNKNOWN_FIELD_KEY`. Publishing warns first, and the MCP tool `form_publish` returns the same messages in `warnings`.

- Publishing stops on a clash: two fields with one key (questions, repeating groups, hidden fields and calculated fields share one set of keys), a key already published on another field, two options of one question with one key, or a hidden field whose parameter name is invalid or used twice. A dropped field key or option key is only ever a warning. The same checks run when the API or an agent publishes.

- Each option of a choice question, and each row and column of a matrix, has an option key, derived from its label the same way and unique within its question. A workflow can branch on `answers.plan == "pro"` whatever language the recipient used.

A repeating group has its own key, and so does each field inside it. The members are nested, never top-level:

```json
{ "contacts": [{ "name": "Ada", "email": "ada@acme.com" }, { "name": "Grace", "email": "grace@acme.com" }] }
```

A hidden field's key is also its URL parameter on a share link. A calculated field's name is its key, so its value comes back as, for example, `answers.total`.

## Create the request

```json
{
  "method": "requests.create",
  "params": {
    "formId": "j57...",
    "recipient": { "email": "ada@acme.com", "name": "Ada" },
    "prefill": { "company_name": "Acme", "company_size": "51_200" },
    "readonly": ["company_name"],
    "context": { "case_id": "CASE-9" },
    "metadata": { "crm_record": "rec_88" },
    "delivery": "email",
    "externalId": "run-42",
    "callbackUrl": "https://automation.example/webhook/resume-abc",
    "idempotencyKey": "run-42"
  }
}
```

The reply carries the request `id`, its `status` (`pending`), the recipient's `url`, `deliveryStatus` and `expiresAt`; the full response is under [requests.create](https://formstep.io/docs/developers/rest-api/#requests-create). Store the `url` if you deliver the link yourself.

A request carries four kinds of values. What each one means for the recipient is explained in [Prefill, locked fields and context](https://formstep.io/docs/requests/creating-requests/#three-buckets). The API rules:

| Parameter | Takes | Rules |
| - | - | - |
| prefill | Answers by field key, for visible questions | The recipient can change them. Each value must fit its type. |
| readonly | A list of prefilled keys to lock | Every key must also be in prefill (READONLY\_REQUIRES\_PREFILL), and a locked required field needs a non-empty value (READONLY\_REQUIRED\_EMPTY). |
| context | Values for the form's hidden fields | Each key must be a hidden field on the published form (UNKNOWN\_FIELD\_KEY; a visible question gives CONTEXT\_KEY\_NOT\_HIDDEN\_FIELD). A value is a string, number or boolean. Request links ignore URL parameters, so the recipient cannot change it. |
| metadata | Any object of your own | Never reaches the form. Comes back in every callback and read. |

## Value shapes

Send each value in the shape its `type` asks for. A wrong shape fails with `INVALID_PREFILL_VALUE`, and the error names the key, the expected type and, for a choice question, the accepted option keys.

| Type | Send |
| - | - |
| text, email, phone, url, textarea | A string |
| number, rating, scale | A number |
| switch | true or false |
| date | "2026-03-04" |
| time | "09:30" or "09:30:00" |
| radio, select | The option key, not its label |
| checkbox, ranking, picture-choice | A list of option keys |
| matrix | An object of row key to column key: { "row\_key": "column\_key" } |
| group (repeating) | A list of at most 100 items: \[{ "member\_key": value }, ...] |
| file, signature, payment, schedule-appointment | Nothing; the recipient supplies these |
| any field with calculated: true | Nothing; the form works it out |
| documents | Nothing in prefill; use the documents parameter below |

## Documents for one recipient

A [Documents block](https://formstep.io/docs/building-forms/documents-block/) shows files to the recipient. A request can add files for its one recipient, such as their own contract, below the files every recipient gets. You upload the file to a URL Formstep gives you, so the bytes do not pass through the API:

1. **Reserve the upload**

   Call [documents.create](https://formstep.io/docs/developers/rest-api/#documents-create) with the formId, a name, the contentType, the exact size in bytes and, if you like, a sha256. You get back an id and an uploadUrl that is valid for one hour.

2. **Upload the bytes**

   PUT the file to uploadUrl with the same Content-Type.

3. **Reference it on the request**

   Pass documents: \[{ documentId, field?, name? }] on requests.create. Formstep checks the uploaded file before the request is created.

```json
"documents": [
  { "documentId": "kn7...", "name": "Your lease contract" },
  { "documentId": "kn8..." }
]
```

`name` replaces the display name for this request. Set `field` to the block's field key when the form has more than one Documents block. One upload can be used by any number of requests. The accepted types and the size caps are listed under [documents.create](https://formstep.io/docs/developers/rest-api/#documents-create). The callback lists the files this recipient was given, under the block's field key.

## Invitations and reminders

`delivery: "email"` has Formstep email the invitation; it needs `recipient.email`. The default, `"none"`, leaves the link to you. Invitations and reminders need Pro or Business. On Free, an invitation uses one of the [free invitations](https://formstep.io/docs/subscription-billing/limits-quotas/#free-invitations) and goes only to your own address: when they are used up the call fails with `FREE_INVITATIONS_USED`, and another address fails with `FREE_INVITATIONS_OWN_ADDRESS_ONLY`. Your own address is the one you sign in with, or the one your API token or MCP connection belongs to, and spellings of one address count as one: a `+tag` or dots in a Gmail address do not make a new one. A deduplicated call and a test request spend no free invitation. The email itself, and how the reminder schedule runs, are explained in [Invitations, reminders and expiry](https://formstep.io/docs/requests/invitations-and-reminders/).

### Delivery status

| deliveryStatus | Meaning |
| - | - |
| not\_requested | Created with delivery "none". You deliver the link. |
| queued | Accepted, not sent yet. |
| sent | Handed to the email provider. |
| failed | Formstep could not send it, usually a plan or address problem. Fix it, then send a reminder: it carries the same link. |
| bounced | The provider reported a hard bounce or a complaint. Create a new request for the right address; a reminder will not help. |

### Email limits

- One recipient address gets at most 10 emails a day from one form, invitations and reminders together. Over it, the call fails with `RATE_LIMITED` and reason `RECIPIENT_EMAIL_LIMITED`.

- An account sends at most 30 invitations and reminders an hour in its first 7 days, then 200 an hour. Over it, the call fails with reason `INVITATION_RATE_LIMITED`; retry after `retryAfterMs`. A scheduled reminder over it goes out later.

- A request emails its recipient at most nine times: the invitation and up to eight reminders, manual and scheduled together. [requests.remind](https://formstep.io/docs/developers/rest-api/#requests-remind) sends one now, at least 10 minutes after the last.

### The reminders parameter

Without `reminders`, a request with a recipient email follows the form's schedule when **Send reminders** is on. `reminders` replaces that schedule for one request, and applies even when the setting is off:

- Up to five offsets of idle time, in whole days, hours or minutes, each at least one hour: `["2d", "12h", "90m"]`.

- `[]` sends no reminders.

- A schedule of your own needs a recipient email and Pro or Business. A duplicate offset, an offset under one hour or a sixth step fails with `INVALID_REMINDER_SCHEDULE`.

## Other options

| Option | What it does |
| - | - |
| language | The language the form opens in and the invitation is written in: one of the form's published languages, or LANGUAGE\_NOT\_PUBLISHED. The recipient can still switch. |
| expiresAt | When the link stops working, in epoch milliseconds. Default 30 days out, at most 365 (EXPIRY\_OUT\_OF\_RANGE). |
| externalId | Your own id for the request. Comes back on every read and callback, and filters requests.list. |
| callbackUrl | Where Formstep POSTs the event when the request ends. See [Request callbacks](https://formstep.io/docs/developers/callbacks/). |
| domainId | The custom domain the link is minted on. Without it, the link uses the domain the form already has a share link on. Needed when there are several. |
| form | Build the form in the same call instead of sending formId. See [Inline forms](https://formstep.io/docs/developers/inline-forms/). |

### Test requests

`test: true` runs the whole flow without a real recipient. The call is checked like a real one, the link opens and can be completed, the callback fires with `"test": true`, and `requests.get` returns the answers. A test request:

- sends no invitation and no reminders, and costs nothing from the monthly allowance;

- keeps its answers out of the inbox, submission counts, exports, integrations, subscriptions and notifications;

- is hidden on the Requests page until you turn on **Show test requests**, and left out of `requests.list` unless you pass `includeTest: true`;

- closes within 24 hours, whatever `expiresAt` says.

On Free, a workspace can create 10 test requests a day. The next fails with `RATE_LIMITED` and reason `TEST_REQUEST_LIMIT_REACHED`.

### Idempotency

Send the same `idempotencyKey` with the same body, and you get the original request back with `deduplicated: true`: no second request and no second email. The same key with a different body fails with `IDEMPOTENCY_CONFLICT`. Keys apply per workspace for 30 days. In a workflow tool, use the execution id as the key, so a retried run finds the request it already created.

### What a request costs

Each request spends one unit of the workspace's [monthly allowance](https://formstep.io/docs/subscription-billing/limits-quotas/) when it is created, answered or not. When the allowance is spent, `requests.create` fails with `UPGRADE_REQUIRED` and reason `MONTHLY_ALLOWANCE_REACHED`. Requests already created can still be answered.

### Rate limit

`requests.create` and `documents.create` share a limit of 60 calls a minute per token, on top of the [general limit](https://formstep.io/docs/developers/overview/#rate-limits). A call over it fails with `RATE_LIMITED` and can be retried after `retryAfterMs`.

## Read the answers

Wait for the [callback](https://formstep.io/docs/developers/callbacks/), or read the request with [requests.get](https://formstep.io/docs/developers/rest-api/#requests-get). Once `status` is `completed`, it holds two maps keyed by field key: `answers` for code (option keys, numbers, file lists with download URLs) and `display` for people (labels, file names). A request is deleted once the form's retention window passes after it ends, so read what you need when it ends.

### Outcome

`status` says whether the request finished; `outcome` says what the recipient decided. A [decision question](https://formstep.io/docs/requests/decisions-and-approvals/) is a radio keyed `decision` whose options have the keys `approve`, `decline` and `changes`. When the recipient picks one of the three, the request completes with that outcome:

```json
"request": { "id": "kd7...", "externalId": "run-42", "status": "completed", "outcome": "changes" },
"answers": { "decision": "changes", "reason": "Please split the line items per site." }
```

- The outcome is on `data.request.outcome` in the callback and next to `status` on `requests.get`. The verdict is also an ordinary answer under `decision`.

- `requests.list` takes an `outcome` filter, which implies completed requests only.

- There is no outcome when the recipient picks a fourth option, when the option keys were changed (publishing warns about that), when the decision question sits inside a repeating group, or when the request expired or was canceled.

## From an AI agent

Over the [MCP server](https://formstep.io/docs/developers/mcp-server/), the same calls are the tools `fields_list`, `request_create`, `request_get`, `request_list`, `request_cancel`, `request_remind` , `request_replayCallback` and `document_create`, with the same parameters.

- `fields_list` adds a `usage` line to each key that names the argument it belongs in. Read it again after `form_publish`.

- Every request tool's result has a `next` line telling the agent what to do from there.

- The ids for `domainId` come from `formShareLink_list`, as `availableCustomDomains`.

- `editor_insertDecisionQuestion` adds a decision question; it takes a title and the three labels in the form's language.

- An agent with nobody to set up a receiver leaves out `callbackUrl` and polls `request_get` every few minutes until `status` leaves `pending`, then reads `outcome`. See [Callbacks or polling](https://formstep.io/docs/developers/mcp-server/#requests-callbacks).

- A `request_list` page can come back with no items and `hasMore: true`. The list goes on, so pass the cursor back and keep going.

[Request callbacks](https://formstep.io/docs/developers/callbacks/) — What arrives when the request ends, and how to verify it.

[Error reference](https://formstep.io/docs/developers/rest-api/#error-reference) — Every error code and reason, and what to do.

# Create a form and a request in one call

Send the blocks with the request: Formstep builds the form, publishes it and returns the link, with no editor in between.

## When to use it

A request usually points at a form you built in the editor. When the questions depend on the case, for example an agent on the phone learns that a recipient still has to upload two payslips and pay a fee, send `form` instead of `formId`. Formstep builds an ordinary form from the blocks, publishes it, and creates the request. Either both are created or neither is. This is an **inline form**.

Use `formId` for a form you send to many people in the same shape. Use `form` when a workflow decides what to ask.

## The call

```bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer $FORMSTEP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"method":"requests.create","params":{
        "form":{
          "name":"Documents and fee for your mortgage application",
          "logo":{"type":"image","url":"https://acme.example/logo.png"},
          "cover":{"type":"color","color":"#0f4c81"},
          "emoji":"🏦",
          "blocks":[
            {"type":"paragraph","content":"Upload the two documents and pay the processing fee."},
            {"type":"file","key":"payslips","title":"Your last two payslips","maxFiles":2},
            {"type":"payment","key":"fee","title":"Processing fee","amount":40,"currency":"EUR"},
            {"type":"scheduleAppointment","key":"call","title":"Pick a time for our call","eventType":"15 min call"}]},
        "recipient":{"email":"ada@example.com","name":"Ada"},
        "delivery":"email",
        "idempotencyKey":"call-8841"}}'
```

All other [request options](https://formstep.io/docs/developers/requests-api/) work as usual. The response adds the form and the keys its answers come back under:

```json
{
  "id": "kd7...",
  "status": "pending",
  "url": "https://form.formstep.io/r/rq_...",
  "form": {
    "id": "j58...",
    "name": "Documents and fee for your mortgage application",
    "reused": false,
    "fields": [
      { "key": "payslips", "type": "file" },
      { "key": "fee", "type": "payment" },
      { "key": "call", "type": "schedule-appointment" }
    ]
  }
}
```

To prefill a choice question in the same call, send option keys, as for any form. An option’s key is derived from its label, so `Self-employed` becomes `self_employed`. A label instead of a key fails with `INVALID_PREFILL_VALUE`, which lists the valid keys.

## The form object

| Field | What it does |
| - | - |
| name | Required, 1 to 100 characters. The name on the dashboard and in the browser tab, the same for every recipient. Personalise through prefill and context, never through the name. |
| blocks | Required, 1 to 200 blocks, in the order the form shows them. The blocks in a row's columns and a repeating group's fields count too. |
| language | The form's UI language. Default: English. |
| logo, cover, emoji | Your brand, sent with every request. logo: { "type": "icon", "name": "HeartIcon" } or { "type": "image", "url": "https\://…" }. cover: { "type": "color", "color": "#0f4c81" } or { "type": "image", "url": "https\://…", "offsetY": 50 }. emoji: one emoji beside the name. See below. |
| theme | A look per color mode: { "light": "solarized-light", "dark": "dracula" }. Each mode takes a preset id, free on every plan, or a whole theme object as the MCP tool formTheme\_set takes it, which needs the Pro plan. |
| settings | The form behaviour the settings page sets: branding, a verified email domain (emailDomainId), owner and respondent emails with their copy, the reminder schedule, the invitation email, the redirect and retention. Fields by key: respondentNotificationTo is the key of an email question, redirectQueryParams maps a parameter to a field key. Email copy is plain text with {{key}} placeholders. No password or sign-in gate and no share-link setting such as bot protection or edits after submit: a request carries its own link. Any other key is refused. |
| translations | The form in more languages, with a language switcher for the recipient: { "de": { "Your name": "Ihr Name" } }, every string of the form keyed by the text as the blocks wrote it. See below. |

## Blocks

A block takes the parameters of the matching `editor_insert*` tool on the [MCP server](https://formstep.io/docs/developers/mcp-server/), without `formId` and `after`, plus an optional `key`. Without one, the key is derived from the title, so “Full name” becomes `full_name`. A parameter the block does not know is refused rather than dropped.

| Kind | Types |
| - | - |
| Questions | text, contact, number, date, time, radio, checkbox, select, switch, rating, linearScale, matrix, ranking, pictureChoice, file, signature, decision |
| Documents | documents, to show files the recipient reads or signs |
| Payments and bookings | payment, scheduleAppointment |
| Content | header, paragraph, image (src URL), embedded, table (cells fills it), list, row (columns: the blocks of each column, 2 to 6 columns, and widths in percent). An optional key lets logic show or hide the block. |
| Structure | pageDivider, repeatingGroup (fields: the member questions as blocks, each with its key), hiddenField (key required: it is the context key), calculatedField (its name is its key), logic |
| AI Validation | aiValidation (sentence: the rule, naming the answers it reads as {{key}}, the questions above it and hidden fields; key: optional, the key its result gets in data.aiValidations) |

Header and paragraph text, list items and table cells read `**bold**`, `*italic*`, `~~struck~~`, `<u>underlined</u>`, `` `code` ``, `$$equation$$`, `[text](https://url)`, `<span color="red" background="yellow" font="font-georgia" size="1.25">…</span>` with the editor toolbar’s colors, fonts and sizes, and `{{key}}`, which shows the value of the field with that key. Marks nest; an unmatched marker is text. A column of a row holds any block except pageDivider, logic, hiddenField, calculatedField, aiValidation, repeatingGroup, row and scheduleAppointment. A `decision` block takes its `title` and `labels` for approve, decline and changes in the form’s language.

An `aiValidation` block checks the answers its sentence names against it with AI while the recipient fills in the form, and shows a note when they do not fit; the recipient can still submit. For example,

`{ "type": "aiValidation", "key": "invoice_check", "sentence": "{{invoice}} is an invoice for order {{order_number}}, dated in 2026." }`

right after an `invoice` file question, with `order_number` a hidden field the request’s context fills. Its result arrives as `data.aiValidations.invoice_check`. See [AI Validation](https://formstep.io/docs/building-forms/ai-validation/).

Blocks refer to each other by key, never by element id: a `logic` block takes its `rule` as `editor_insertLogic` does, with field keys where it wants ids, option labels as condition values and page names for `setNextPage`.

### Payments

A `payment` block takes `amount` and `currency`, or `amountFromField`: the key of a hidden field or a number question. With a hidden field, each request passes its own amount in `context`, so one form charges every recipient their own amount:

```json
{
  "form": {
    "name": "Pay your invoice",
    "blocks": [
      { "type": "hiddenField", "key": "invoice_total" },
      { "type": "payment", "key": "invoice", "title": "Invoice", "currency": "EUR", "amountFromField": "invoice_total" }
    ]
  },
  "recipient": { "email": "ada@example.com" },
  "context": { "invoice_total": 1200 }
}
```

A number question that sets the amount must be required and have a `min` above 0, so nobody reaches checkout with an empty, zero or negative amount.

The form charges through the workspace’s default Stripe account, or the one `connection` names. A person [connects Stripe once for the workspace](https://formstep.io/docs/building-forms/payment-collection/#connect-stripe); an automation cannot. Without a usable account the call fails and tells you where to connect it.

### Bookings

A `scheduleAppointment` block takes `eventType`, the title or id of an event type on the workspace’s [Cal.com](https://formstep.io/docs/integrations/cal-com/) connection, and `connection` when the workspace has several. Formstep looks the event type up when the request is created. `prefill: { name, email }` names the questions whose answers fill in the booking.

## Your brand

An automation has no way to know your look, so you set it up once and the automation sends it with every request: `logo`, `cover` and `emoji` beside the blocks, and `theme` for the colors and type.

| Field | Takes |
| - | - |
| logo | An icon from the builder, { "type": "icon", "name": "HeartIcon" }, or your own image, { "type": "image", "url": "https\://…/logo.png" }. |
| cover | A solid color, { "type": "color", "color": "#0f4c81" }, or an image, { "type": "image", "url": "https\://…/cover.jpg", "offsetY": 50 }, where offsetY from 0 to 100 picks the part of a tall image that shows. |
| emoji | One emoji, shown beside the form name. |

Every image, the `src` of an image block included, must be a public JPEG, PNG or WebP. Formstep fetches each once when the form is created and keeps its own copy, so the form and the PDFs of its submissions outlive the source. A URL that cannot be fetched fails the call with `INLINE_FORM_IMAGE_UNREACHABLE` naming the field. Without a brand, the form looks like a new form from the dashboard.

## Translations

`translations` publishes the form in more languages at once, and the recipient gets the language switcher a translated form has: `{ "de": { "Your name": "Ihr Name", "Order for **{{customer}}**": "Bestellung für **{{customer}}**" } }`. Every string of the form must be there, keyed by the text exactly as the blocks wrote it: question titles, options, placeholders, headers, paragraphs, list items, table cells, group names and button labels. Markup and `{{key}}` stay as in the source. A string missing fails the call with `INLINE_FORM_TRANSLATION_MISSING`, which lists the strings to add. Email copy is not translated.

## Repeat calls reuse the form

A later call with the same `form` object reuses the same form, and the response says `"reused": true`. A hundred requests with one definition make one form, with one set of answers to export. Changing a block, the name, the brand or a translation makes a new form; requests already sent keep the form they were created with. A form you close for submissions or unpublish is not reused: the next call with the same definition makes a new one.

> **Personalise through prefill and context**
>
> Put the recipient’s name, the invoice number or the amount in `prefill` and `context`, never in the form name, a title or a fixed amount. A definition that changes per recipient makes a new form per recipient. Set `key` on every question: a derived key follows the title, so a retitled question answers under a new key. A workspace makes at most 300 new inline forms a day, and calls that reuse a form do not count.

When a call makes a new form although an open inline form has the same block types in the same order, the response names that form in `form.similarForm`. Usually a changed name or title caused it. To read every answer to one inline form, list its submissions instead of reading each request.

## On the dashboard

An inline form belongs to its request, so it lives on the request’s page rather than on the Forms page. The page lists the answers by field key, and **Preview** opens the form as the recipient saw it, filled in with their answers. A link to an inline form’s editor or submissions opens the Requests page filtered to that form instead. Agents still see inline forms in `form_list`, and the API refuses to change one.

An inline form has no share link, and none can be added: its requests are the only way in, and the callback carries every answer. It has no integrations either.

Because it has no page of its own, an inline form is never deleted by hand. It goes with its last request: when you [delete a request](https://formstep.io/docs/requests/managing-requests/#delete), or the form’s retention window takes it, and no other request still uses the form, the form is deleted too.

## When the call is refused

Each refusal carries a `details.reason` your automation can branch on, and nothing is created. All of them are `VALIDATION_ERROR` except the daily limit, which is `RATE_LIMITED`.

| Reason | What to do |
| - | - |
| FORM\_SOURCE\_MISSING / FORM\_SOURCE\_AMBIGUOUS | Send exactly one of formId and form. |
| INLINE\_FORM\_INVALID | A block or a field of form fails its schema. details.issues names each one. |
| INLINE\_FORM\_DUPLICATE\_KEY | Two blocks resolve to the same key. Give one of them its own key. |
| INLINE\_FORM\_UNKNOWN\_KEY | A logic rule, amountFromField or a booking prefill names a key no block has. |
| INLINE\_FORM\_AMOUNT\_MISSING | Pass a positive number for the amount field in context. |
| INLINE\_FORM\_DAILY\_LIMIT | The workspace made 300 new inline forms today. This one comes back as RATE\_LIMITED with retryAfterMs; calls that reuse a form keep working. |
| INLINE\_FORM\_CONNECTION\_MISSING | Stripe or Cal.com is not connected. details.connectUrl is the page a person opens once to connect it. |
| INLINE\_FORM\_EVENT\_TYPE\_NOT\_FOUND | No Cal.com event type matches. The error lists the ones there are. |
| INLINE\_FORM\_IMAGE\_UNREACHABLE | An image URL is no public JPEG, PNG or WebP. details.field names it. Use a URL anyone can open, or drop the image. |
| INLINE\_FORM\_TRANSLATION\_MISSING | A translation lacks strings the form has. details.missing lists them, as written in the blocks. |
| INLINE\_FORM\_PUBLISH\_ISSUES | Publishing refused the form, for instance an event type that cannot be booked from a form. |

## From an agent

On the MCP server, `request_create` takes the same `form`. Before building a form with a payment or a booking, an agent reads `workspaceConnection_list`: it shows the connected Stripe and Cal.com accounts and, when one is missing, the link a person must open once. `scheduler_listEventTypes` lists the event types a booking can name, and `paymentConnection_setDefault` changes the default Stripe account. Over the REST API the same three are `connections.list`, `scheduler.listEventTypes` and `paymentConnections.setDefault`.

[Create requests from code](https://formstep.io/docs/developers/requests-api/) — Prefill, locked fields and context for any form.

[Field keys](https://formstep.io/docs/developers/requests-api/#field-keys) — How keys are derived and kept stable.

[Request callbacks](https://formstep.io/docs/developers/callbacks/) — Receive the answers when the request ends.

[Payment collection](https://formstep.io/docs/building-forms/payment-collection/) — Connect Stripe for the workspace.

# Request callbacks

Formstep POSTs a signed event to the callbackUrl of a request when it is completed, expires or is canceled. Read the payload, verify the signature, and handle retries and replays.

Set `callbackUrl` on [requests.create](https://formstep.io/docs/developers/rest-api/#requests-create). It must be HTTPS and resolve to a public address, so `localhost` does not work; while you test, use a tunnel as in [Receive and verify the callback](https://formstep.io/docs/developers/quickstart-receive-the-callback/).

## What fires, and when

| Event | When |
| - | - |
| request.completed | The recipient submitted. Carries the answers. |
| request.expired | The expiry passed while the request was still pending. |
| request.canceled | You or your automation withdrew it. |

All three go to the same URL, so check `type` before you read answers. An expiry reaches you within 15 minutes of `expiresAt`.

## What arrives

```json
{
  "id": "evt_kj7...",
  "type": "request.completed",
  "createdAt": "2026-03-04T09:31:40.000Z",
  "apiVersion": "2026-09-24",
  "test": false,
  "data": {
    "request": {
      "id": "kd7...",
      "externalId": "run-42",
      "status": "completed",
      "outcome": "approve",
      "language": "en",
      "recipient": { "email": "ada@acme.com", "name": "Ada" },
      "metadata": { "runId": "run-42" },
      "context": { "case_id": "CASE-9" },
      "createdAt": "2026-03-04T09:20:00.000Z",
      "completedAt": "2026-03-04T09:31:40.000Z"
    },
    "form": { "id": "j57...", "name": "Vendor onboarding", "snapshotId": "kx2..." },
    "submission": {
      "id": "jd7...",
      "respondentEmail": "ada@acme.com",
      "submittedAt": "2026-03-04T09:31:40.000Z",
      "updatedAt": null,
      "editCount": 0,
      "pdfUrl": null,
      "language": "en"
    },
    "answers": { "company_name": "Acme", "contacts": [{ "name": "Ada" }] },
    "display": { "company_name": "Acme", "contacts": "Ada" },
    "aiValidations": {
      "company_check": { "status": "passed", "fields": ["company_name"] }
    }
  }
}
```

- `data.request` is always there, with your `externalId`, `metadata` and `context` unchanged. It carries the timestamp of whichever ending happened: `completedAt`, `expiredAt`, or `canceledAt` with an optional `cancelReason`.

- `outcome` is present only when the recipient answered a [decision question](https://formstep.io/docs/developers/requests-api/#outcome): `approve`, `decline` or `changes`. Expired and canceled requests have none.

- `form`, `submission`, `answers` and `display` appear on completion only, in the shape a [submission webhook](https://formstep.io/docs/developers/webhooks-reference/#payload) carries. `form.snapshotId` is the published version the recipient answered. `submission.pdfUrl` is a URL only when the form keeps a submission PDF.

- `answers` is keyed by [field key](https://formstep.io/docs/developers/requests-api/#field-keys), with repeating groups nested as one object per item. A choice answer is the option key; its label is in `display` under the same key.

- `aiValidations` is there when the form has [AI Validations](https://formstep.io/docs/building-forms/ai-validation/). Its fields are described on the [webhooks reference](https://formstep.io/docs/developers/webhooks-reference/#payload).

- `test` is `true` for a [test request](https://formstep.io/docs/developers/requests-api/#test-mode). Branch on it, or drop the event.

[requests.sample](https://formstep.io/docs/developers/rest-api/#requests-sample) returns this envelope for any form without a real request, which helps when you map fields before the first one ends.

> **Deduplicate on id**
>
> `id` stays the same across every retry and every replay of the same event. If acting twice would hurt, such as a second invoice or a second ticket, store the ids you have handled and skip a repeat.

## Headers

- `Content-Type: application/json` and `User-Agent: Formstep`

- `X-Formstep-Event-Id` and `X-Formstep-Event-Type`, the same values as `id` and `type` in the body, so you can deduplicate and route before parsing

- `X-Formstep-Signature: t={unix seconds},sha256={hex}`, on every callback

## Verify the signature

A callback is signed exactly like a webhook: the same header, an HMAC-SHA256 of the timestamp, a dot and the raw body. Use the verifier on [Webhooks reference: Signing](https://formstep.io/docs/developers/webhooks-reference/#signing). Two things differ:

- The key is the workspace's request signing secret, one secret for every callback in the workspace. A webhook uses its own secret.

- Every callback is signed. A webhook is signed only when it has a secret.

To find the secret, open **OAuth and API Keys** in the workspace sidebar and look for the **Request signing secret** card. Only the workspace owner sees it: click **Reveal secret**, then copy it. The owner can read it again at any time. The secret is never returned over the API or MCP, which is why an agent with nobody to set up a receiver should [poll instead](https://formstep.io/docs/developers/mcp-server/#requests-callbacks).

> **Regenerating has no grace period**
>
> When the owner regenerates the secret, the old one stops working at once, even for callbacks being retried. Update your receiver first, then regenerate.

When a valid callback fails the check, the cause is almost always the body. Parsing the JSON and serializing it again changes the bytes, so hash the body exactly as it arrived. The other common cause is a regenerated secret the receiver has not picked up yet.

> **A resume URL is not authentication**
>
> A workflow tool's hard-to-guess resume URL can leak into logs, and it does not prove who sent the body. Verify the signature anyway.

## Retries

Answer with a 2xx within 10 seconds. A callback gets eight attempts over about four hours, and every attempt sends the same body with the same `id`, as it was when the request ended.

| Your response | What Formstep does |
| - | - |
| 2xx | Done. The callback is marked delivered. |
| 408, 429, 5xx | Retries, and honours Retry-After when you send one. |
| Timeout or connection error | Retries on the same schedule. |
| Any other 4xx | Stops. Your endpoint refused the body, and sending it again cannot help. |
| Blocked URL | Stops at once. A host that does not resolve, a private address or a non-HTTPS URL cannot become allowed. Redirects are never followed, so a 3xx stops too. |

A callback gets a longer budget than a webhook, because a workflow run is waiting on it:

| | Webhook | Request callback |
| - | - | - |
| Attempts | 5, over about two hours | 8, over about four hours |
| A 408 from your endpoint | Stops | Retried |
| When attempts run out | After 5 failed deliveries in a row, the integration pauses until someone clicks Reactivate | The request gets a Callback failed badge and the owner one email; nothing pauses |

## When a callback never lands

The request page shows the callback URL and what happened to it. `requests.get` carries `callbackFailedAt` while the callback has run out of attempts. The answers are not lost: `requests.get` still returns them.

1. Check the URL. A workflow tool's resume URL belongs to one run, and a run that was deleted or re-created no longer answers on it.

2. Check what your endpoint returned. A 4xx other than 408 or 429 stops the retries at once, so a receiver that rejects a valid body never gets a second try.

3. Fix the receiver, then send the callback again: **Replay** on the request in the [Requests page](https://formstep.io/docs/requests/managing-requests/), or [requests.replayCallback](https://formstep.io/docs/developers/rest-api/#requests-replay-callback). A replay sends the same body with the same `id`, so a receiver that deduplicates is safe.

## Subscriptions hear the same events

A callback URL belongs to one request. To send every request of a form to one receiver, subscribe once instead: the Zapier, Make and n8n apps do this for you, and from code you call [webhooks.create](https://formstep.io/docs/developers/rest-api/#webhooks-create) with `request_completed`, `request_expired` or `request_canceled`. A subscription gets the same body, signed with its own secret, under its own event id, with the webhook retry budget. A request with both a callback URL and a subscription sends to both. Test requests reach no subscription, and **Replay** resends only the callback.

[Receive and verify the callback](https://formstep.io/docs/developers/quickstart-receive-the-callback/) — Run a receiver on your machine and watch a callback arrive.

[Webhook API reference](https://formstep.io/docs/developers/webhooks-reference/) — The answers and display maps, and the verifier.

# 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

# API tokens

Create, manage, and rotate tokens for the API and MCP server. Learn the token format, the limits on each token, and what to do first if one leaks.

## Format

A token is `fs_` followed by 32 alphanumeric characters (35 in total). Send it as a bearer token:

```text
Authorization: Bearer fs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

The same header works for [the API](https://formstep.io/docs/developers/rest-api/) and the [MCP server](https://formstep.io/docs/developers/mcp-server/). Only a SHA-256 hash is stored, so a lost token cannot be recovered. Create a new one.

## Limits

- Each person can hold 10 tokens per workspace. Access tokens issued when you connect an OAuth app (`fso_...`) sit on the same page but do not count.

- You pick the expiry when you create the token: 30 days, 90 days or No expiration. There is no extend button: to change it, create a new token and delete the old one. A token that runs an unattended integration, such as an n8n workflow, should use No expiration.

- A token is bound to the workspace it was created in. A call that names another workspace, or a form in one, fails with `FORBIDDEN`, even when you are a member of both.

- A token has no scopes: it does everything you can do in that workspace. There is no read-only token.

> **Every plan has API access**
>
> Creating and using tokens is not limited by plan. Individual calls still are: scheduling request reminders needs Pro or Business, for example, and answers with `UPGRADE_REQUIRED` otherwise.

## Create a token

1. **Open OAuth and API Keys**

   In the sidebar, under your workspace. Tokens live in the API Keys card.

2. **Click Create token**

   Name it in the Create API token dialog, for example "CI/CD Pipeline" or "n8n production", and pick how long it lasts under Expiration: 30 days, 90 days or No expiration.

3. **Copy it now**

   The API token created dialog shows the value once. Copy & Close, and the value is gone for good.

4. **Store it in your secrets manager**

   Never commit it, and never ship it to a browser.

The list then shows the name, the first 11 characters, when it was created, and when it expires, or **Never expires**. An expired token is marked **Expired** and stops authenticating.

## Rotate a token

1. **Create the replacement**

   Give it a name that says which generation it is, like "n8n production v2".

2. **Roll it out**

   Update every consumer. Both tokens work while you switch.

3. **Delete the old one**

   Use the trash icon on its row and confirm in Delete API token?.

## Revoke a token

Deleting a token removes it permanently and it stops working on the next call. There is no grace period, and anything still using it starts getting `401 UNAUTHORIZED`. You can also rename a token with the pencil icon (up to 64 characters); renaming does not change its value.

Only you see your own tokens. Workspace admins cannot list or delete them. Leaving the workspace, or being removed from it, deletes them for you.

> **A token is full access**
>
> Anyone holding it can act as you inside that workspace. If one leaks, delete it first and investigate second.

## OAuth instead

A third-party app connecting on behalf of a user should use the OAuth flow instead of asking for a token. It gets an `fso_...` access token, valid one hour and refreshable for 30 days, bound to the one workspace the user picked at consent. The user sees and disconnects those apps in **Connected apps** on the same page. See [MCP server](https://formstep.io/docs/developers/mcp-server/#oauth).

## Next steps

[MCP server](https://formstep.io/docs/developers/mcp-server/) — Use Formstep from AI agents

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

# MCP server

Use Formstep from Claude, ChatGPT, Cursor and other MCP clients. Connect with OAuth or an API token so AI agents can build forms, send requests and read the answers.

MCP is an open standard that lets AI tools call outside services. Formstep hosts an MCP server that any client with remote MCP support can connect to, such as Claude, ChatGPT and Cursor. To connect one step by step, see [Connect an AI agent](https://formstep.io/docs/guides/ai-agents/connect/).

## Connection

```text
URL:  https://api.formstep.io/api/mcp   (POST, streamable HTTP)
Auth: Bearer <token>
```

Two kinds of bearer token work:

- An [API token](https://formstep.io/docs/developers/api-tokens/) (`fs_...`), for your own scripts and headless agents.

- An OAuth access token (`fso_...`), issued by the [OAuth flow](#oauth). Most clients get one for you when you sign in.

Both reach exactly one workspace. A tool call that names another workspace, or a form in one, fails with `FORBIDDEN`. OAuth tokens also carry scopes: `mcp:read` reaches the tools that only read, and `mcp:write` reaches every tool. `tools/list` shows only the tools a token may call, and any other call answers `403` with `insufficient_scope`. API tokens have no scopes and reach every tool.

Tool calls share the limit of 120 a minute per token with the [REST API](https://formstep.io/docs/developers/rest-api/). Only `tools/call` counts; `initialize`, `tools/list`, `prompts/*` and `resources/*` are free. Over the limit, the call still returns HTTP 200, with a failed tool result carrying `RATE_LIMITED` and `retryAfterMs`. Poll on a timer, never in a loop.

## Tools

The server describes its own tools on `tools/list`, each with a full schema, and clients that load schemas on demand fetch one when a task needs it. The tools cover forms and the editor, themes, settings, share links, translations, folders, webhooks, analytics, submissions and requests. Longer guides, such as every question type or how logic rules work, are served as `skill://` resources and through the `load_skill` tool, so an agent reads them when it needs them.

## Requests

An agent sends a request the same way code does: `fields_list`, then `request_create`, with the same parameters as the REST API. [Create requests from code](https://formstep.io/docs/developers/requests-api/#agents) covers the parameters and what is different over MCP, and [Inline forms](https://formstep.io/docs/developers/inline-forms/) covers building the form in the same call.

### Callbacks or polling

With a `callbackUrl`, Formstep POSTs once when the request ends, signed with the workspace's request signing secret; see [Request callbacks](https://formstep.io/docs/developers/callbacks/). That secret is shown only on the **OAuth and API Keys** page, and never returned over MCP or the API. An agent that runs on its own, with nobody to set up a receiver, therefore cannot verify a callback. It should leave out `callbackUrl` and poll `request_get` every few minutes until `status` leaves `pending`, then read `answers` and `outcome`. `expiresAt` bounds how long that is worth doing.

## Tools that ask first

Every tool carries the MCP hints `readOnlyHint`, `destructiveHint` and `openWorldHint`. These are marked destructive, because undoing them takes another call or is not possible: `form_delete`, `form_unpublish`, `workspaceFolder_delete`, `editor_deleteElement`, `translationLanguage_delete` and `request_cancel`. So are the tools that send something outside the workspace, such as `request_create`, and the tools that replace a stored value, such as `form_publish`, `formTheme_set` and `editor_updateElement`. Most clients ask before running them, but the prompt is the client's choice, so check its approval settings if you need a hard stop.

## Limitations

A tool call cannot carry a file. Images are set by URL: covers, logos and image blocks take an `http(s)://` or `data:image` URL. A per-request document is the exception: `document_create` returns an upload URL that a client with HTTP access can `PUT` the file to, as in [Documents for one recipient](https://formstep.io/docs/developers/requests-api/#documents). To turn a PDF or a screenshot into a form, use the [built-in AI chat](https://formstep.io/docs/ai/using-ai/#files).

## Connecting with an API token

Most clients sign in with OAuth: add the URL with no header and follow [Connect an AI agent](https://formstep.io/docs/guides/ai-agents/connect/), which also has the setup for Cursor. A client that cannot open a browser, such as a script, a CI job or a headless agent, sends an [API token](https://formstep.io/docs/developers/api-tokens/) as a header instead. Claude Code, from the command line:

```bash
claude mcp add --transport http formstep https://api.formstep.io/api/mcp \
  --header "Authorization: Bearer fs_YOUR_TOKEN"
```

Or in a project's `.mcp.json`. Other clients take the same URL and header; see their documentation for where.

```json
{
  "mcpServers": {
    "formstep": {
      "type": "http",
      "url": "https://api.formstep.io/api/mcp",
      "headers": {
        "Authorization": "Bearer fs_YOUR_TOKEN"
      }
    }
  }
}
```

## Using OAuth instead of API tokens

An app that connects on behalf of a user should use OAuth instead of asking for a pasted token. Formstep is an OAuth 2.1 authorization server with mandatory PKCE (S256) and opaque tokens: no JWTs and no implicit grant. Claude, Claude Code and other clients discover it all from the MCP endpoint, so the URL with no header is enough: the 401 points at `/.well-known/oauth-protected-resource`, and the client takes it from there.

The flow, for a client you write yourself:

1. `GET /.well-known/oauth-protected-resource`, then `GET /.well-known/oauth-authorization-server` for the endpoint URLs, scopes and supported auth methods.

2. `POST /oauth/register` with your `redirect_uris` (dynamic client registration, no credentials needed). You get a `client_id`, plus a `client_secret` if you asked for anything other than `token_endpoint_auth_method: "none"`. Redirect URIs must be HTTPS, or HTTP on `localhost`. Registration is capped at 20 an hour per IP.

3. Send the user to `/oauth/authorize` with `response_type=code`, your `client_id`, the registered `redirect_uri`, `scope=mcp:read mcp:write offline_access`, `state`, and a `code_challenge` with `code_challenge_method=S256`. They sign in, pick one workspace, and authorize.

4. Exchange the code at `POST /oauth/token` with `grant_type=authorization_code` and your `code_verifier`, within 60 seconds. A code works once.

5. Call the MCP endpoint with `Authorization: Bearer fso_...`. Access tokens last 1 hour. Refresh tokens last 30 days and rotate on every use; reusing a spent refresh token revokes the whole chain, so always store the newest one.

`POST /oauth/revoke` (RFC 7009) revokes an access or refresh token.

### Connected apps

Every OAuth connection is listed under **Connected apps** on the **OAuth and API Keys** page, with when it was connected and last used. Connections are personal: only the user who authorized one sees it, and workspace admins cannot see or revoke another member's. Disconnecting revokes every token the app holds for that workspace at once. A user who leaves or is removed from the workspace loses all their connections to it.

[Connect an AI agent](https://formstep.io/docs/guides/ai-agents/connect/) — Set up Formstep in any MCP client.

[Create requests from code](https://formstep.io/docs/developers/requests-api/) — The request parameters an agent sends.

For what an agent can do with it, see [the MCP server overview](https://formstep.io/integrations/mcp/).

# Webhook API reference

Payload schema, event types, signing, retries, and REST subscription endpoints. Read it to parse, verify, and dedupe the events Formstep sends to your URL.

> **Looking for the setup guide?**
>
> To set up a custom webhook for your form in the app, see [Custom webhooks](https://formstep.io/docs/integrations/webhooks/).

## Request

`POST <your-url>` with `Content-Type: application/json`. The URL must use HTTPS, carry no credentials, be at most 2,048 characters, and resolve to a public address. The address is checked again, DNS included, before every delivery.

### Headers

- `Content-Type: application/json`

- `X-Formstep-Signature: t={timestamp},sha256={hex}`, present when a signing secret is set up (see below)

- `X-Formstep-Event-Id` and `X-Formstep-Event-Type`, which carry the same values as `id` and `type` in the body, so you can dedupe and route before parsing. A [request callback](https://formstep.io/docs/developers/callbacks/) sends the same two.

- Up to 5 custom headers you add during setup. They are merged in as given, except `Content-Type`, which cannot be overridden. Native subscriptions created through `webhooks.create` have no custom headers.

## Event types

### Subscription events

When you set up a webhook integration, you choose which event triggers deliveries:

| Event | When it fires |
| - | - |
| submission\_created | A respondent completes and submits the form. This is the default. |
| submission\_updated | A respondent edits a submission they already sent, when the form allows editing after submit. |
| submission\_abandoned | A draft submission has been idle past the configured window. Requires partial submission tracking (Pro and Business). |
| request\_completed | A recipient completes a request on the form. Carries the request block and the answers. |
| request\_expired | A request on the form expires before the recipient completes it. Request block only. |
| request\_canceled | A request on the form is canceled. Request block only. |

The three `request_*` events are subscribed through `webhooks.create` and are what the Formstep apps for Zapier, Make and n8n listen on. Each delivers the same envelope a [request callback](https://formstep.io/docs/developers/callbacks/) sends, signed with the subscription’s own secret. Test requests never reach a subscription, and `requests.replayCallback` re-sends the callback only. Each subscription listens to one event. `submission_created` is share-link traffic and `request_completed` is request traffic, so a completed request never fires `submission_created` and a form with both subscriptions receives one delivery per completion. An edit reaches a `submission_updated` subscription alone, never a `submission_created` one. The webhook you add in the form’s Integrations dialog has no event choice: it receives first submissions and edits alike, told apart by `type`.

### Payload event types

The `type` field in the JSON body tells you what happened:

- `submission.completed`: a new completed submission

- `submission.updated`: an existing submission was edited

- `submission.abandoned`: a draft submission was abandoned after the configured idle window

A [request callback](https://formstep.io/docs/developers/callbacks/) and a `request_*` subscription use the same envelope with `request.completed`, `request.expired` and `request.canceled`, so one parser reads all six.

## Payload shape

```json
{
  "id": "evt_abc123",
  "type": "submission.completed",
  "createdAt": "2026-04-25T12:34:56.000Z",
  "apiVersion": "2026-09-24",
  "test": false,
  "data": {
    "form": { "id": "frm_...", "name": "Contact", "snapshotId": "snp_..." },
    "submission": {
      "id": "sub_...",
      "respondentEmail": "alice@example.com",
      "submittedAt": "2026-04-25T12:34:56.000Z",
      "updatedAt": null,
      "editCount": 0,
      "pdfUrl": null,
      "language": "en"
    },
    "answers": {
      "email": "alice@example.com",
      "plan": "pro",
      "attendees": [{ "attendee_name": "Grace Hopper" }, { "attendee_name": "Alan Turing" }]
    },
    "display": {
      "email": "alice@example.com",
      "plan": "Pro",
      "attendees": "Grace Hopper, Alan Turing"
    }
  }
}
```

| Key | What it is |
| - | - |
| id | The event id. Retries reuse it, so deduplicate on it. |
| type | One of the six event types above. |
| createdAt | When the event was queued, not when this delivery attempt was made. Stable across retries. |
| apiVersion | The payload contract, as a date. It changes when a key is removed, renamed or changes meaning. New keys arrive without a change. |
| test | true for a sample or test delivery, and for a request created in test mode. Always present. |
| data.form.snapshotId | The published version the respondent answered. Field keys, titles and types are fixed per snapshot. |
| data.submission.updatedAt | When the respondent last edited the submission, or null until the first edit. |
| data.submission.editCount | How many times the respondent edited the submission after sending it: 0 on submission.completed, 1 on the first edit. |
| data.answers | Every answer, keyed by field key. Each answer appears once. |
| data.display | Human-readable text for every answer, under the same keys. |
| data.schema | Optional: the field list (key, title, type, group, and the option or row and column keys with their labels), when the webhook was set up to send it. |
| data.aiValidations | Present when the form has AI Validations: one entry per check under its key, with status (passed, flagged or not\_checked), the fields it read by field key, a reason when it flagged the answers, and a notCheckedReason (no\_plan or no\_credits) when the plan or the AI credits kept it from running. See AI Validation. |

### answers and display

`answers` is the flat `{ field key: value }` object, keyed by the field keys frozen at publish. Read it when a workflow branches or stores a value: `answers.email`. A choice answer is the chosen option’s key (the `key` that `fields.list` lists for that option), so it is the same whatever language the respondent answered in. A date is an ISO string, a number a number, a multi-select an array of option keys. Unanswered fields are left out, never sent as `null`.

`display` carries the same keys with human-readable text: the option label instead of its key, a formatted date, a joined list. Read it when a person will see the value, for example in a Slack message, a spreadsheet cell or an email.

A repeating group appears in `answers` once, under the group’s own field key, as an array of row objects keyed by each member’s field key (`answers.attendees[0].attendee_name` above), and in `display` as one line with the rows joined. A member is never hoisted to the top level. [Calculated fields](https://formstep.io/docs/building-forms/calculated-fields/) appear in both maps under the calculated field’s name as its key (`answers.total`).

### Bookings and payments

A Schedule appointment question and a Payment question each carry an object in `answers`, under the question’s field key, and one line of text in `display`. The times are ISO instants, so a spreadsheet or a workflow can parse them whatever language the respondent answered in:

```json
{
  "book_a_call": {
    "status": "confirmed",
    "start": "2026-09-29T07:00:00.000Z",
    "end": "2026-09-29T07:30:00.000Z",
    "timeZone": "Europe/Oslo",
    "attendee": { "name": "Grace Hopper", "email": "grace@example.com" },
    "meetingUrl": "https://app.cal.com/video/...",
    "provider": "cal.com",
    "providerBookingId": "...",
    "eventTitle": "Intro call"
  },
  "pay_the_fee": {
    "status": "paid",
    "amount": 40,
    "currency": "USD",
    "amountRefunded": 0,
    "receiptUrl": "https://pay.stripe.com/receipts/...",
    "paidAt": "2026-09-24T10:12:00.000Z",
    "refundedAt": null,
    "disputedAt": null,
    "provider": "stripe",
    "providerPaymentIntentId": "pi_..."
  }
}
```

A booking’s `status` is `confirmed`, `rescheduled`, `cancelled`, `rejected` or `no_show`. A payment’s is `paid`, `partially_refunded`, `refunded` or `disputed`, and `amount` is in the currency’s main unit: `40` is $40.00. When Cal.com moves a booking or Stripe refunds a payment after the submission, Formstep updates the object, so `submissions.list` and later events show the current state; no new event is sent for the change.

Before `apiVersion` `2026-09-24`, a booking was sent as one sentence in `answers` and a payment was not sent at all.

The webhook’s field mapping applies to both maps at once: choose “selected” fields and the others are left out; rename a field’s column and the new name is its key in `answers` and `display` alike. A field the form published before field keys existed goes out under its element id; publish the form again to give it a readable key.

### Field titles and types

The event does not repeat each field’s title and type. Read them from `fields.list`, which is stable per `data.form.snapshotId`, so you can cache the field list and refetch only when the snapshot id changes. A receiver that cannot make a second call can turn on **Send the field list with every event** in the webhook’s settings; the event then carries `data.schema`, one entry per field. A choice question lists its `options` and a matrix its `rows` and `columns`, each as `{ key, label }`, so the keys in `answers` resolve to labels without a second call:

```json
[
  { "key": "email", "title": "Email", "type": "email", "group": null },
  { "key": "plan", "title": "Plan", "type": "radio", "group": null, "options": [{ "key": "free", "label": "Free" }, { "key": "pro", "label": "Pro" }] },
  { "key": "attendee_name", "title": "Attendee name", "type": "text", "group": "attendees" }
]
```

### The request block

On the [custom webhook](https://formstep.io/docs/integrations/webhooks/) added in the form’s Integrations dialog, a submission that answered a [request](https://formstep.io/docs/requests/overview/) carries one extra object inside `data`, `request`. It is absent on every public-link submission, so its presence is how that receiver tells the two channels apart. A Zapier, Make or n8n subscription never sees it: request traffic reaches a subscription as `request.completed`, which carries the full request block.

```json
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
```

- `id`: the request this submission answered. Pass it to `requests.get` for the full picture.

- `externalId` and `metadata`: your own bookkeeping, exactly as you supplied it on `requests.create`. Each is present only when it was set.

> **Webhooks are not callbacks**
>
> A submission webhook fires on a submission; the request block only names the request it answered. A [callback](https://formstep.io/docs/developers/callbacks/) fires when a request ends (completed, expired, or canceled) and carries `context` and `outcome`. Expiry and cancellation have no submission, so no submission webhook ever fires for them. To hear a request end without a callback URL, subscribe to `request_completed`, `request_expired` or `request_canceled` through `webhooks.create`.

### AI Validation results

Each [AI Validation](https://formstep.io/docs/building-forms/ai-validation/) check reports under its own key in `data.aiValidations`. A check is keyed `ai_validation`, `ai_validation_2` and so on, until someone gives it a name in the editor's Fields tab. Callbacks, `requests.get` and the MCP tool `formSubmission_list` carry the same object.

```json
{
  "website_check": { "status": "passed", "fields": ["company_website"] },
  "email_domain": {
    "status": "flagged",
    "reason": "The work email uses gmail.com, not the company's domain maersk.com.",
    "fields": ["work_email", "company_website"]
  }
}
```

Branch on `status`: send a flagged submission to a person to review, or send the recipient a new request for the missing document.

### Documents blocks

A [Documents block](https://formstep.io/docs/building-forms/documents-block/) records which files the respondent was shown, under the block's field key. `source` is `form` for a file the author uploaded and `request` for one a request added. A checkbox that confirms reading them answers under its own key.

```json
{
  "documents": [
    { "name": "Price list 2026", "source": "form" },
    { "name": "Your lease contract", "source": "request" }
  ],
  "contract_read": true
}
```

### Submission PDF

`data.submission.pdfUrl` is a link to the submission PDF. A form with a custom webhook or a Zapier, Make or n8n subscription keeps a PDF of every submission, so their events carry the link. It is `null` when the submission has no PDF, or when the PDF could not be rendered.

### File URLs

`pdfUrl` and every file answer are `/api/storage/` URLs. The key in the path is random and unguessable, and anyone who holds the URL can download the file for as long as it exists: there is no sign-in, and the URL itself does not expire (each visit redirects to a signed download that lasts an hour). Treat these URLs as part of the submission data: store them where only your systems read them, keep them out of logs and tickets, and do not forward them by email or chat. Formstep follows the same rule: its notification emails attach the PDF rather than link to it. Deleting the submission deletes the file, and the URL then answers 404.

### Submission language

`data.submission.language` is the BCP-47 code of the language the respondent submitted in (for translated forms). It is `null` for single-language forms. Use it to route or branch on the respondent’s language without a separate lookup.

## Abandoned submission payloads

When you subscribe to `submission_abandoned`, Formstep checks for idle drafts every hour. If a draft has been inactive past the configured idle window, Formstep fires a delivery.

Native API integrations must send `idleWindow` to `webhooks.create`. Accepted values are `12h`, `1d`, `3d`, and `1w`. There is no implicit default; an abandoned subscription without a value is rejected.

| Idle window | Description |
| - | - |
| 12 hours | For same-day follow-ups |
| 1 day | A reasonable gap before nudging |
| 3 days | For less urgent forms |
| 1 week | For low-frequency forms |

The payload has the same shape as a completed submission, with two differences:

- Answers may be sparse: only questions the respondent answered appear in `answers` and `display`.

- `submittedAt` falls back to the dispatch timestamp, since the respondent never formally submitted.

Each integration fires at most once per abandoned draft. After delivery, the draft is excluded from future sweeps.

## Signing

Each webhook has its own signing secret. A custom webhook gets a `whsec_` secret when you set it up, which only the workspace owner can reveal later. A subscription gets one from the optional `signingSecret` parameter (32 to 255 characters) on `webhooks.create`. It is not the workspace request signing secret used for [request callbacks](https://formstep.io/docs/developers/callbacks/#verify), but the header and the algorithm are identical, so the verifier below handles both.

Every signed delivery carries `X-Formstep-Signature: t={seconds},sha256={hex}`. The digest is an HMAC-SHA256 of `{t}.{raw body}`, keyed with your secret. Hash the raw body before any parsing or re-serializing, and compare in constant time.

**Node.js**

```javascript
import crypto from 'node:crypto'

export function verifyFormstepSignature(rawBody, header, secret, toleranceSeconds = 300) {
  if (!header) return false
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
  if (!parts.t || !parts.sha256) return false
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(parts.sha256, 'hex')
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false
  return Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds
}
```

**Python**

```python
import hashlib, hmac, time

def verify_formstep_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split('=', 1) for p in (header or '').split(',') if '=' in p)
t, received = parts.get('t'), parts.get('sha256')
if not t or not received:
return False
expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received):
return False
return abs(time.time() - int(t)) <= tolerance_seconds
```

Formstep does not enforce a replay window, so the tolerance above is yours to choose. Changing the secret takes effect on the next attempt, including retries already in flight. Update your receiver first.

> **Always verify in production**
>
> Without verification, anyone who discovers your URL can post fake submissions.

## Retries

Answer with a 2xx within 10 seconds. A delivery gets up to 5 attempts, at least 1, 2, 4 and 8 minutes apart. Formstep looks for due retries every 30 minutes, so the last attempt comes about two hours after the first. A `Retry-After` header is honoured when it asks for longer than the next step.

- A 429, a 5xx, a timeout or a reset connection is retried.

- Any other 4xx stops at once: your endpoint refused the body, and sending it again cannot help. A 408 stops too, unlike for a [request callback](https://formstep.io/docs/developers/callbacks/#retries).

- A destination that is blocked, does not resolve, or resolves to a private address is never retried. The URL is checked again, DNS included, before every attempt.

A 401, 403 or 404 pauses the integration at once and emails the person who set it up. After 5 failed deliveries in a row, the integration pauses too. Fix the endpoint, then open Integrations on the form, pick the webhook under **On this form**, and click **Reactivate**. A successful delivery resets the count.

Every attempt of one event carries the same `id` and `createdAt`, so deduplicate on `id`. An edit by the respondent is a separate event with its own `id` and `type: "submission.updated"`, sent to the form's custom webhook or a `submission_updated` subscription. `data.submission.editCount` counts the edits, and `data.submission.updatedAt` says when the latest one happened.

## Testing

The integration setup and detail panel both have a **Send test** button. It fires a sample of the subscribed event to your URL so you can verify the connection without waiting for a real submission or request. A sample answer is made up per type, such as “John Doe” for text, 42 for a number and <john@example.com> for an email, and it is signed and carries your custom headers. The same samples are available over the API as `submissions.sample` and `requests.sample`.

Formstep only delivers to public HTTPS addresses, so `localhost` does not work. While you develop, give your machine a public address with a tunnel:

```bash
# Cloudflare quick tunnel, no account needed
cloudflared tunnel --url http://localhost:3000

# or ngrok

ngrok http 3000
```

> **Local development**
>
> Use the tunnel URL as the webhook URL and click Send test, or pass it as a request's `callbackUrl`.

## Next steps

[Webhooks setup](https://formstep.io/docs/integrations/webhooks/) — Configure webhooks for your form

[Plans & pricing](https://formstep.io/docs/subscription-billing/plans-pricing/) — Compare plan API features and limits
