Documentation

How to send submissions to FormTo from any site, what the API expects, and how to integrate webhooks, file uploads, CAPTCHA, and the REST API — plus how to run FormTo yourself with the open-source self-hosted build.

Overview

FormTo gives each form a public HTTPS URL. Visitors submit with a normal browser POST; we store the payload, run spam checks, deliver notification email, and fire webhooks you configure in the dashboard.

Base URL: https://api.formto.dev

Submission endpoint: POST /f/:endpoint:endpoint is the slug shown in the app (e.g. https://api.formto.dev/f/contact).

Each form also has a hosted page at the same URL via GET /f/:endpoint — see Hosted form pages.

Quick start

  1. Create an account and add a form in the dashboard.
  2. Copy the form's full URL — it always looks like https://api.formto.dev/f/your-slug.
  3. Point your HTML <form> at that URL with method="POST".
  4. Submit once from your site; open the dashboard to see the submission.

Prefer not to sign up? See Self-hosted (open source) to run the whole thing on your own infrastructure.

HTML forms

Classic forms send application/x-www-form-urlencoded or multipart/form-data (for file uploads on Professional+). Field names become keys in the stored submission.

<form action="https://api.formto.dev/f/your-endpoint" method="POST">
  <input type="text" name="name" placeholder="Name" required />
  <input type="email" name="email" placeholder="Email" required />
  <textarea name="message" placeholder="Message" required></textarea>
  <button type="submit">Send</button>
</form>

Replace your-endpoint with your real slug from the app. Use any input names you need; avoid reserved honeypot names listed in Spam & honeypots unless you intend them as traps.

JavaScript & fetch

You can POST JSON from JavaScript. If you hit CORS issues from the browser, prefer a native HTML form, or POST from your own backend or serverless route (Next.js Route Handler, Netlify Function, etc.) — same URL and body.

fetch("https://api.formto.dev/f/your-endpoint", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    name: "Ada",
    email: "ada@example.com",
    message: "Hello",
    _formto_ts: Date.now(),
  }),
})
  .then((r) => r.json())
  .then(console.log)
  .catch(console.error);

React / Next.js: keep the POST on the server when possible (Server Action or Route Handler) so you can validate input and avoid exposing unusual client-side-only assumptions. The HTML form approach works without any JS at all.

Hosted form pages

Don't want to build your own HTML? Every form also has a ready-to-use hosted page at GET https://api.formto.dev/f/:endpoint. The server renders a branded page with your configured fields, spam protection, and (if enabled) CAPTCHA.

Share this URL directly, embed it in an iframe, or link to it from your site:

<a href="https://api.formto.dev/f/contact">Contact us</a>

Submissions from hosted pages land in the same inbox as submissions from your own HTML forms and honour the same redirect, autoresponder, and webhook configuration.

File uploads

Attach files to submissions by posting multipart/form-data with <input type="file">. File uploads require Professional plan or higher.

Per-plan limits:

  • Free / Personal — file uploads disabled
  • Professional — up to 10 MB per file
  • Business — up to 25 MB per file
  • Enterprise — up to 50 MB per file

Accepted MIME types: images (JPEG, PNG, GIF, WebP), PDF, Word (DOC/DOCX), Excel (XLS/XLSX), ZIP, and plain text (TXT, CSV). File signatures are verified server-side — renaming .exe to .pdfwon't fool the validator.

<form action="https://api.formto.dev/f/your-endpoint"
      method="POST"
      enctype="multipart/form-data">
  <input name="name" required />
  <input name="email" type="email" required />
  <input name="attachment" type="file" accept=".pdf,image/*" />
  <button type="submit">Send</button>
</form>

File URLs are stored on the submission record and visible in the dashboard and CSV export.

Spam & honeypots

Public endpoints are spam targets. FormTo applies layered checks — honeypot fields, timing analysis, rate limiting, and (optionally) CAPTCHA — on every public submission. All plans, including Free, get honeypot and timing protection by default.

Honeypot fields must stay empty and hidden from real users. Any submission where one of these fields is non-empty is silently dropped:

website, url, website_url, home_page, fax, phone2, phone_2, secondary_phone, _hp, _honeypot, honeypot, hp_field, _gotcha, gotcha, _trap, trap, address2, company_website, your_website

Timing check (_formto_ts): send a Unix timestamp in milliseconds (e.g. Date.now()) as _formto_ts with the submission. Anything submitted less than 3 seconds after the form loaded is scored as a bot and dropped.

CAPTCHA

On top of honeypots and rate limits, forms on Professional plan and above can enable CAPTCHA verification. FormTo supports two providers out of the box:

  • Cloudflare Turnstile — privacy-friendly, no annoying image puzzles
  • Google reCAPTCHA v3 — score-based (we require score ≥ 0.5), invisible to real users

Enable CAPTCHA in the form settings, paste your site key and secret key, and include the provider's client script on your page. Submissions without a valid token are rejected with 400 Bad Request.

Blocklist

When honeypots aren't enough, add a per-form blocklist to silently reject specific senders. Each entry has a type and a value:

  • email — exact email address, e.g. spammer@example.com
  • domain — email domain, e.g. example.com
  • ip — client IP address

Blocked submissions return 200 OK with a fake submissionId so the attacker doesn't get feedback about which filters exist. Nothing is saved, no email is sent, no webhook fires.

Auto-close forms

Close a form automatically when it hits a cap or a deadline — useful for event RSVPs, limited signups, or surveys with a fixed window. Configure in form settings:

  • By count — set close_after_submissions to N and the form stops accepting new responses after N submissions
  • By date — set close_at to an ISO timestamp and the form closes at that moment

Once closed, the endpoint returns 422 Unprocessable Entity with {"error": "Form closed"}. The count check is atomic — no race conditions even under high traffic.

Autoresponder

On Personal plan and above, enable an automatic reply sent to the submitter's email address as soon as they submit. Configure per form:

  • The email field whose value is used as the recipient (e.g. email)
  • Subject line and HTML body

Template variables: use {{variable}} syntax in both subject and body. Built-in variables:

  • {{form_name}} — name of the form
  • {{submission_date}} — formatted date (e.g. "April 6, 2026")

You can also use any field name from the submission as a variable. For example, if your form has fields name and email, you can write:

Hi {{name}}, thanks for reaching out!

We received your message on {{submission_date}} and will reply to {{email}} shortly.

— The {{form_name}} Team

Field values are HTML-escaped to prevent XSS. Unknown variables (e.g. {{typo}}) are left as-is so you can spot mistakes. Autoresponder emails go out after the submission is saved — they never fire for honeypot or blocklist rejections.

Notifications

Beyond email notifications, FormTo can send real-time alerts to Slack, Discord, and Telegram whenever a form receives a new submission. Available on Personal plan and above.

Setup: configure your channel credentials once in Account → Notifications, then toggle each channel on or off per form in Form Settings → Notifications.

Slack

  1. Go to api.slack.com/apps → Create New App → "From scratch"
  2. In the left menu: Incoming Webhooks → toggle On
  3. Click Add New Webhook to Workspace → pick a channel
  4. Copy the webhook URL and paste it in Account → Notifications → Slack

Submissions are formatted as rich Slack Block Kit messages with field names, values, and a timestamp.

Discord

  1. Right-click your channel → Edit Channel → Integrations
  2. Click Webhooks → New Webhook → give it a name
  3. Copy the webhook URL and paste it in Account → Notifications → Discord

Submissions are sent as Discord embeds with an indigo accent, field list, and footer.

Telegram

  1. Open Telegram and message @BotFather → send /newbot
  2. Follow the prompts to name your bot, then copy the Bot Token
  3. Start a chat with your new bot (send it any message) or add it to a group/channel
  4. Message @userinfobot for your personal Chat ID, or use @RawDataBot in a group to get the group ID
  5. Paste the Bot Token and Chat ID in Account → Notifications → Telegram

Submissions are formatted as MarkdownV2 messages with up to 20 fields, each truncated to 500 characters. For channels, use the numeric ID starting with -100.

Custom SMTP

On Professional plan and above, you can send autoresponder emails from your own domain instead of FormTo's default sender. This means your customers see hello@yourdomain.com instead of notifications@formto.dev.

Setup: go to Account → Notifications → Custom SMTP and enter your SMTP server details:

  • Host — your SMTP server (e.g. smtp.postmarkapp.com, smtp.mailgun.org, smtp.gmail.com)
  • Port — typically 587 (STARTTLS) or 465 (SSL/TLS)
  • Username & Password — your SMTP credentials
  • From email — the sender address (must be allowed by your SMTP server)
  • From name (optional) — e.g. "Your Company"

Use the Send test email button to verify your configuration before enabling it. Once enabled, all autoresponder emails for your forms are routed through your SMTP server.

Fallback: if your SMTP server is temporarily unreachable (timeout, auth failure), FormTo automatically falls back to its default sender so the submitter always receives a reply. Failed SMTP attempts are logged in the server console.

Security: your SMTP password is encrypted at rest using AES-256-GCM and never returned to the browser after saving. The SMTP host is validated against private IP ranges to prevent internal network access.

Responses & errors

Success: 201 Created with JSON body:

{
  "success": true,
  "message": "Form submitted successfully",
  "submissionId": "uuid-here",
  "redirect": "https://example.com/thanks"
}

The redirect field is the URL configured in form redirect settings (or null if not set). HTML forms posting without JavaScript will follow a 303 redirect automatically when one is configured.

Common errors:

  • 400 — validation failure, missing CAPTCHA token, or invalid file type
  • 403 — form disabled in dashboard
  • 404 — unknown or mistyped endpoint slug
  • 422 — form closed (auto-close by count or date)
  • 429 — rate limit exceeded; slow down and retry
  • 503 — CAPTCHA provider unreachable (rare)

Rate limits

Public submissions are rate limited per IP and per endpoint. Default is 5 requests per minute per IP per form. Requests above the ceiling get a 429 until the window resets.

Authenticated REST API calls (with an API key) have a higher ceiling — 60 requests per minute by default. If you outgrow defaults for legitimate traffic, contact us and we'll tune limits for your account.

Redirects after submit

Set a thank-you URL in the form settings. For HTML form posts, visitors are redirected there after a successful submission. JSON callers receive the redirect URL in the response body instead (see Responses & errors) so you can navigate client-side.

Webhooks

Configure a webhook URL per form in the dashboard. On each new submission, FormTo sends a JSON POST to your endpoint. Delivery attempts, response codes, and retries are visible in the app so you can debug failed calls. Webhooks are available on Personal plan and above; automatic retry on failure is added on Professional.

Payload:

{
  "event": "form.submitted",
  "form": {
    "id": "form-uuid",
    "name": "Contact",
    "endpoint": "contact"
  },
  "submission": {
    "id": "submission-uuid",
    "data": {
      "name": "Ada",
      "email": "ada@example.com",
      "message": "Hello"
    },
    "created_at": "2026-04-05T12:34:56.000Z"
  }
}

Signature verification: every request carries two headers:

  • X-FormTo-Timestamp — Unix timestamp in milliseconds
  • X-FormTo-Signature — HMAC-SHA256 (hex) of `${timestamp}.${rawBody}`

Verify the signature on your end before trusting the payload:

import crypto from 'node:crypto'

function verify(req, secret) {
  const ts = req.headers['x-formto-timestamp']
  const sig = req.headers['x-formto-signature']
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${ts}.${req.rawBody}`)
    .digest('hex')
  return crypto.timingSafeEqual(
    Buffer.from(sig, 'hex'),
    Buffer.from(expected, 'hex')
  )
}

Slack, Discord & Telegram: FormTo also supports native notifications to Slack, Discord, and Telegram — see the Notifications section for setup instructions.

REST API (v1)

Plans with API access (Professional and above) can use the REST API for programmatic reads. Authenticate with an API key created in Account → API Access:

X-API-Key: fto_<your_api_key>

Base URL and routes:

  • GET https://api.formto.dev/v1/forms — list your forms
  • GET /v1/forms/:formId/submissions — list submissions for a form (supports ?limit, ?offset)
  • POST /v1/keys, GET /v1/keys, DELETE /v1/keys/:keyId — manage API keys (requires a Clerk-authenticated session in the dashboard)
curl -H "X-API-Key: fto_your_key" \
  https://api.formto.dev/v1/forms

Dashboard-only operations (create form, templates, CSV export UI) use Clerk-authenticated routes under /api/... — those are not part of the public API.

Health check

GET https://api.formto.dev/health returns service status JSON for monitoring and uptime checks.

Self-hosted (open source)

Open Source · AGPL-3.0

Prefer to own the whole stack? FormTo has an open-source, self-hosted build. It runs on your own server with Docker Compose — no SaaS account, no vendor lock-in, no data leaving your infrastructure.

→ Full self-hosted documentation

Jump straight to a section:

Quick start (30 seconds):

git clone https://github.com/lumizone/formto
cd formto
cp formto.env.example formto.env   # edit DOMAIN, POSTGRES_PASSWORD, JWT_SECRET
docker compose up -d

Open https://your-domain.com→ first-run wizard → create your admin account → done. HTTPS is automatic via Caddy + Let's Encrypt. See the full self-hosted guide for everything else.

Need help?