# formto docs (full text)
> Every page of https://formto.io/docs, in order. Code samples are verbatim.
---
# What formto does
formto is the backend for the forms on your website. You keep your own HTML; formto receives each response, checks it, stores it, emails it to you and sends it wherever you work.
- **One endpoint per form.** Your form posts to `https://api.formto.io/f/
```
## 4. Send a test response
```sh
curl -fsS "https://api.formto.io/f/$(node -p 'require("./form.json").publicId')" \
-H 'accept: application/json' \
-d name=Ada -d email=ada@example.com -d message='Hello from the quickstart'
```
The answer is `{"success":true,...}`, and the owner gets an email.
## 5. Read your responses
```sh
curl -fsS "https://api.formto.io/v2/submissions?formId=$(node -p 'require("./form.json").id')" \
-H "Authorization: Bearer $FORMTO_TOKEN"
```
They are also in the dashboard under [Responses](/responses). Next: [restyle the form](/docs/build) or [send responses to Slack or a webhook](/docs/connect).
---
# Add a form to your site
Any HTML form works: point its `action` at your form's endpoint and use `method="post"`. The fastest way to a form that matches your fields is the generator, which the CLI, the MCP tools and the dashboard all use.
## Generate it
```console
npx -y https://formto.io/dl/cli-latest.tgz init --yes --name "Quote request"
```
`init` creates the form, sets its fields, writes `formto-quote-request.html` and allows submissions only from the site in `package.json` `homepage`. Agents call `formto_generate_form_code` for the same HTML or a React component.
## What the generated form contains
- A `` for every field and the right input type (`email`, `tel`, `url`, `number`, `date`).
- Radio buttons for four options or fewer, a `` above that.
- The `_gotcha` honeypot: hidden from people, filled by bots, dropped by formto.
- A small script that submits with `fetch` and shows a thank-you message. Without JavaScript the form still posts normally and formto shows its own thank-you page.
Generate, then restyle: change classes and layout freely, but keep the field names, the `action` URL and the honeypot.
## Submit with JavaScript
The endpoint answers JSON when you ask for it, so your own script can show errors inline:
```html
Send
```
## Thank-you pages
Set a success redirect on the form and formto sends the browser there after a normal (non-JavaScript) post, adding `submission_id` and `status=success` to the URL. Without one, formto shows a plain thank-you page.
See [Works with](/works-with/html) for where the code goes on WordPress, Webflow, Squarespace, Shopify and others, and [Templates](/templates) for ready-made forms.
---
# Define your fields
A definition lists a form's fields: their names, types, labels and whether they are required. formto uses it to validate responses, label them in emails and the dashboard, and generate form code.
```json
{
"version": 2,
"title": "Quote request",
"fields": {
"name": { "type": "string", "label": "Your name", "required": true },
"email": { "type": "email", "label": "Email", "required": true },
"service": { "type": "select", "label": "Service", "options": [{ "value": "mowing" }, { "value": "hedges" }] },
"details": { "type": "text", "label": "Anything else?" }
},
"fieldOrder": ["name", "email", "service", "details"]
}
```
Field types: `string`, `text`, `email`, `phone`, `url`, `number`, `boolean`, `date`, `datetime`, `select`, `multiselect`, `rating`, `scale`, `file` and `hidden`. Names are letters, digits, `_`, `.` or `-`; names starting with `_` are reserved for formto.
## Set a definition
```sh
FORM_ID=$(curl -fsS https://api.formto.io/v2/forms \
-H "Authorization: Bearer $FORMTO_TOKEN" -H 'content-type: application/json' \
-d '{"name":"Quote request"}' | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).id')
curl -fsS -X PUT "https://api.formto.io/v2/forms/$FORM_ID/definition" \
-H "Authorization: Bearer $FORMTO_TOKEN" -H 'content-type: application/json' \
-d '{"version":2,"fields":{"name":{"type":"string","required":true},"email":{"type":"email","required":true}}}'
```
Changing a definition never touches responses you already have.
---
# Read responses
Every response is in the dashboard under [Responses](/responses), where you can search, filter and export CSV. Each one is also emailed to the workspace owner.
## From the terminal
In a folder where you ran `formto init`:
```console
npx -y https://formto.io/dl/cli-latest.tgz responses
npx -y https://formto.io/dl/cli-latest.tgz responses --json
```
## From the API
Responses come newest first, a page at a time. Pass `nextCursor` back as `cursor` for the next page.
```sh
FORM_ID=$(curl -fsS https://api.formto.io/v2/forms -H "Authorization: Bearer $FORMTO_TOKEN" \
| node -p 'const d = JSON.parse(require("fs").readFileSync(0, "utf8")).data; d.length ? d[0].id : ""')
if [ -n "$FORM_ID" ]; then
curl -fsS "https://api.formto.io/v2/submissions?formId=$FORM_ID&limit=10" -H "Authorization: Bearer $FORMTO_TOKEN"
fi
```
A token needs the `submissions:read` scope to read responses. Deleting needs `submissions:delete`, which is never granted unless you tick it.
## Held responses
When a workspace passes its monthly allowance, new responses are still stored but marked held: they are kept and shown, and are delivered once you upgrade. See [pricing](/pricing) for each plan's allowance.
---
# Spam and allowed origins
formto stops most spam without a CAPTCHA:
- **The honeypot.** The hidden `_gotcha` field is empty for people and filled by bots. formto drops filled ones silently.
- **Allowed origins.** List your site's hostnames on the project and posts from anywhere else are refused with `403 origin_not_allowed`. Subdomains of an allowed host are allowed too.
- **Rate limits.** Each project accepts a set number of posts per minute; bursts above it are refused.
- **Triage.** Responses that look like spam are labelled so they do not clutter your inbox.
## Allow only your site
Create the project with its origins. `formto init` does this for you from `package.json` `homepage`.
```sh
curl -fsS https://api.formto.io/v2/projects \
-H "Authorization: Bearer $FORMTO_TOKEN" -H 'content-type: application/json' \
-d '{"name":"Marketing site","allowedOrigins":["example.com"]}'
```
You can change allowed origins later in the dashboard under Projects. A project with no allowed origins accepts posts from any site.
---
# Send responses somewhere
Every form emails the workspace owner. On paid plans a form can also send each response to a webhook, Slack, Discord or Zapier. Add destinations in the dashboard under a form's Connections, or with the API.
## Add a webhook
```sh
FORM_ID=$(curl -fsS https://api.formto.io/v2/forms \
-H "Authorization: Bearer $FORMTO_TOKEN" -H 'content-type: application/json' \
-d '{"name":"Webhook demo"}' | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).id')
curl -fsS "https://api.formto.io/v2/forms/$FORM_ID/destinations" \
-H "Authorization: Bearer $FORMTO_TOKEN" -H 'content-type: application/json' \
-d '{"kind":"webhook","config":{"url":"https://example.com/formto-webhook"}}'
```
The answer includes the signing `secret` once. Store it with your receiver; formto never shows it again. Webhook URLs must be public HTTPS hosts: private addresses and IP literals are refused.
## Verify a webhook
Deliveries follow Standard Webhooks: headers `webhook-id`, `webhook-timestamp` and `webhook-signature` (`v1,`). Verify before trusting the body:
```js
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(secret, headers, body) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const signed = `${headers['webhook-id']}.${headers['webhook-timestamp']}.${body}`
const expected = createHmac('sha256', key).update(signed).digest('base64')
const fresh = Math.abs(Date.now() / 1000 - Number(headers['webhook-timestamp'])) < 300
return fresh && headers['webhook-signature'].split(' ').some((sig) => {
const value = sig.split(',')[1] ?? ''
return value.length === expected.length && timingSafeEqual(Buffer.from(value), Buffer.from(expected))
})
}
// A self-check with a made-up secret, so this file runs as is.
const secret = 'whsec_' + Buffer.from('formto-docs-example-secret-32byte').toString('base64')
const body = JSON.stringify({ event: 'submission.created' })
const headers = { 'webhook-id': 'msg_1', 'webhook-timestamp': String(Math.floor(Date.now() / 1000)) }
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
headers['webhook-signature'] = 'v1,' + createHmac('sha256', key).update(`${headers['webhook-id']}.${headers['webhook-timestamp']}.${body}`).digest('base64')
if (!verify(secret, headers, body)) throw new Error('verification failed')
console.log('signature ok')
```
Receivers that dedupe on `webhook-id` ignore a replay of a delivery that already succeeded. Failed deliveries are retried with backoff, and every attempt is in the dashboard under Logs, where you can retry by hand.
---
# Connect an AI agent
An agent can create forms, paste working HTML into your site and read responses. You approve it in the browser and choose what it may do. Deleting responses is off unless you tick it, and you can disconnect it at any time under [API keys](/tokens).
## Claude Code, Cursor and ChatGPT
Add formto's remote MCP server. The client opens formto in your browser to sign in and approve.
```console
claude mcp add --transport http formto https://mcp.formto.io/mcp
```
In Cursor and ChatGPT, add a remote MCP server or custom connector with the URL `https://mcp.formto.io/mcp`. The [agent page](/agent) lists every tool.
## Scripts and other bots
Run `npx -y https://formto.io/dl/cli-latest.tgz login`, or create a token under [API keys](/tokens) and give the copy block to your bot. It holds the token, the base URL and three lines on how to call the API. Tokens stop working after a long time unused and can be revoked at once.
## What needs a person
Agents read submitted text as untrusted data. Two actions wait for you to confirm in the dashboard: sending responses to a host the workspace has not used before, and deleting responses.
---
# API reference
The REST API lives at `https://api.formto.io/v2`. The full, machine-readable description is the [OpenAPI spec](https://api.formto.io/v2/openapi.json), generated from the same route definitions the API runs.
## Authentication
Send a token in the `Authorization` header and nowhere else: tokens in URLs are refused.
```sh
curl -fsS https://api.formto.io/v2/me -H "Authorization: Bearer $FORMTO_TOKEN"
```
- `fta_…` agent tokens act for one workspace with the scopes you picked. Create them under [API keys](/tokens) or with `npx -y https://formto.io/dl/cli-latest.tgz login`.
- `fto_live_…` and `fto_test_…` project keys are for server-side integrations.
- Remote MCP clients use OAuth instead; see [Connect an AI agent](/docs/connect/agents).
## Scopes
`forms:read`, `forms:write`, `submissions:read`, `submissions:write`, `submissions:delete`, `connections:manage`, `views:write` and `org:admin`. A call without the scope it needs is `403`.
## Errors
Errors are JSON `{ "error": "", "message": "…" }` with the HTTP status: `400` invalid input, `401` missing or refused token, `402` not on your plan (with `upgradeUrl`), `403` missing scope or role, `404` not found (including another workspace's ids) and `409` a conflict such as replaying a delivery that is still being sent.
## Submitting
`POST https://api.formto.io/f/` takes form-encoded, multipart (with files) or JSON bodies and needs no token. Ask for JSON with `Accept: application/json`.
---
# CLI
```console
npx -y https://formto.io/dl/cli-latest.tgz login # sign in through the browser; saves ~/.formto/credentials.json
npx -y https://formto.io/dl/cli-latest.tgz whoami # the workspace the token acts for
npx -y https://formto.io/dl/cli-latest.tgz init --yes --name Contact # create a form for the site in this folder
npx -y https://formto.io/dl/cli-latest.tgz responses --json # the newest responses of this folder's form
npx -y https://formto.io/dl/cli-latest.tgz logout
```
Options: `--api ` for another API origin, `--json` for machine-readable output. Set `FORMTO_TOKEN` to use a token without a credentials file, for example in CI.
Exit codes: `1` a failure, `2` not signed in or the token was refused, `3` a plan limit.
---
# Google Workspace
formto delivers responses to Google Sheets, Google Drive, Google Docs, Google Calendar, Gmail, Google Contacts and Google Tasks, and posts them to Google Chat. formto hosts no forms of its own in Google: every product is a place a response is sent to after someone submits your form.
## What each product asks Google for
formto asks for the smallest access each product needs, and only when you add that product to a form from its Connections page. Signing in to formto with Google asks for your name and email address only (`openid email profile`), never a product.
- **Google Sheets** adds a row per response to a spreadsheet formto creates. It asks for `https://www.googleapis.com/auth/drive.file`.
- **Google Drive** uploads each response's files to a folder formto creates. It asks for `https://www.googleapis.com/auth/drive.file`.
- **Google Docs** appends each response to a document formto creates. It asks for `https://www.googleapis.com/auth/drive.file`.
- **Google Calendar** creates an event from a date or time field, in a calendar formto creates, with an optional Google Meet link. It asks for `https://www.googleapis.com/auth/calendar.app.created`.
- **Gmail auto-reply** sends one reply from your Gmail address to the person who submitted. It asks for `https://www.googleapis.com/auth/gmail.send`.
- **Google Contacts** adds the person who submitted to a contact group formto creates. It asks for `https://www.googleapis.com/auth/contacts`.
- **Google Tasks** creates a task per response in a task list formto creates. It asks for `https://www.googleapis.com/auth/tasks`.
- **Google Chat** posts each response to a space through its incoming webhook. It asks Google for nothing: you paste the space's webhook URL.
Sheets, Drive and Docs share one access, so adding one of them lets formto add the others without asking again. Each other product asks once, the first time you add it.
## What formto never reads
formto never reads a mailbox, a calendar, Drive files or contacts it did not create. With `drive.file` Google only lets formto open files it created or that you picked for it. With `calendar.app.created` formto sees only the calendar it created. With `gmail.send` formto can send mail but cannot read any. The Contacts access is used only to look up the submitter's email address, so a returning person is added to the group instead of created twice; formto never changes or deletes a contact it did not create.
## Disconnecting
Settings lists each Google account the workspace delivers to and what its access covers. Disconnect revokes formto's access at Google at once. Deliveries to that account stop, and you can connect it again from any form's Connections page. You can also remove formto at any time from your Google Account under Security, Third-party apps.