# FormThunder documentation
> The contact form API and form backend built for AI to configure.
# Quickstart
Source: https://formthunder.com/docs/quickstart
To start with FormThunder, get an access key, add it as a hidden field in a form that posts to `https://formthunder.com/submit`, and send a test. It takes about two minutes by hand. An AI agent connected over MCP can do the same in one prompt. Submissions are stored, then emailed to your verified address.
## With your AI
Add the MCP server, then ask for what you want. Claude Code is shown here, and the [MCP page](/docs/mcp) has config for other clients.
```bash
claude mcp add formthunder -- npx -y formthunder mcp
```
```text
Add a contact form to this site that emails hello@example.com. Test it when you're done.
```
The agent checks your linked emails, creates the form, writes the snippet into your project and sends a real test. It runs `check_form_on_site` once the page is live.
## By hand
### 1. Get an access key
Enter your email on the [home page](/) or [sign up](/signup). We email you an access key and a link that verifies the address. The key is the form's ID, and it's safe to put in your HTML.
### 2. Add the form
```html
```
### 3. Send a test
Open the page, fill in the form and send it. You land on a success page, and the email should reach your inbox within a moment. The dashboard shows the submission and each delivery's status.
## With the CLI
`init` detects your framework, creates a form and prints the code.
```bash
npx formthunder login
npx formthunder init
```
## Next steps
- Set the [subject, reply-to and redirect](/docs/customisation).
- Turn on [spam protection](/docs/spam).
- Match the [emails to your brand](/docs/email-templates).
- Find code for your stack on the [platform pages](/platforms).
---
# Submit API and fields
Source: https://formthunder.com/docs/submit-api
The submit API is one endpoint: `POST https://formthunder.com/submit`, or `POST https://formthunder.com/submit/{formId}` with the key in the path. It accepts JSON, URL-encoded and multipart bodies, CORS is open, and the field names match Web3Forms. Any field that isn't reserved is forwarded as-is, and a field's name is its label in the email.
## Request
```bash
curl -X POST https://formthunder.com/submit \
-H "Content-Type: application/json" \
-d '{"access_key":"YOUR_ACCESS_KEY","name":"Priya","email":"priya@example.com","message":"Hello"}'
```
The access key is the form's ID. It's public by design, because it can only send to recipients you've verified.
## Reserved fields
| Field | What it does |
|---|---|
| `access_key` | Identifies the form. Required unless the ID is in the URL. |
| `subject` | Email subject. Overrides the form's setting. |
| `from_name` | Sender name shown in the inbox. |
| `replyto` | Reply-to address. |
| `email` | The visitor's address. Stored as the sender and used for replies and the autoresponder. |
| `ccemail` | CC addresses, comma-separated. They must be verified linked emails. Pro. |
| `redirect` | Absolute URL to send a plain HTML form post to. |
| `botcheck` | Honeypot. Any value other than `false` or `off` marks the submission as spam. |
| `webhook` | HTTPS URL to post this one submission to. Pro. |
| `attachment` | Files, or upload IDs from the uploader. Pro. |
| `h-captcha-response` | hCaptcha token. |
| `cf-turnstile-response` | Turnstile token. Pro. |
| `g-recaptcha-response`, `recaptcha_response` | reCAPTCHA token. Pro. |
| `preheader` | Preview text in the email. |
A field name ending in `[]` is treated as a list, so `colors[]` arrives as `colors`.
## Response
A JSON or fetch request gets JSON back.
```json
{
"success": true,
"message": "Your message was sent.",
"submission_id": "8f14e45f-...",
"body": { "data": { "name": "Priya", "message": "Hello" }, "message": "..." }
}
```
A plain HTML form post gets a 303 to `redirect`, or to [the default success page](/submit/success). If it fails, the 303 goes to `/submit/error` with the reason. A failed JSON request gets `success: false`, a `message` and sometimes a `hint`. A successful one may also carry `warnings`, for things like ignored files.
## Status codes
| Code | Meaning | What to do |
|---|---|---|
| 200 | Stored. Deliveries are queued. | Nothing. |
| 303 | Stored. Redirecting a plain form post. | Nothing. |
| 400 | Bad request: missing key, bad JSON, failed captcha. | Read `message`. |
| 403 | The form is paused, or the origin isn't on its allowed domains. | Add the domain or resume the form. |
| 413 | Too large. | Bodies stop at 6 MB and direct files at 5 MB. Use the [uploader](/docs/attachments). |
| 429 | Over 30 requests a minute from this IP to this form. | Wait a minute. |
| 503 | We couldn't store it. Sent with `Retry-After`. | Retry after that many seconds. |
## Limits
A submission can hold up to 100 fields, and each value up to 50,000 characters. Longer values are trimmed.
## What happens next
The submission and its delivery jobs are written to Postgres in one transaction before you get a success reply. A background worker then sends the email and any webhooks or chat messages, and retries failures for up to 24 hours. See [how that's built](/reliability).
---
# Customise a form
Source: https://formthunder.com/docs/customisation
You can customise a FormThunder form in two places. Hidden fields in the HTML apply to that form tag, and settings on the form itself live in the dashboard, or through your AI, the CLI or the API. A hidden field wins over the form's setting. Subject, from name, reply-to and redirect work on every plan.
## Subject
```html
```
The form-level subject can use any submitted field as a variable.
```bash
npx formthunder forms update YOUR_FORM_ID --subject "New message from {{name}}"
```
## From name
This sets the name people see in their inbox. The address itself stays FormThunder's sending address.
```html
```
## Reply-to
If the form has a field named `email`, replying to the notification goes to that address. Set `replyto` to override that.
```html
```
## Redirect
A plain HTML form post lands on our success page unless you set a redirect. It must be an absolute `http` or `https` URL.
```html
```
Set `redirect_url` on the form to use the same page for every post. Requests sent with `fetch` get JSON and don't redirect.
## Success page
Without a redirect, visitors see `/submit/success`, a plain confirmation page. It isn't configurable, so send people to your own page if you want your own branding. If a plain form post fails, they see `/submit/error` with the reason.
## CC and recipients
A form can have 1 recipient on Free, 5 on Pro and 10 on Agency. Each must be a verified linked email. CC is on Pro and Agency, and CC addresses have to be verified too.
```html
```
## Intro text
Intro text appears above the fields in the notification email. It's a Pro setting, available as `intro_text` on the form.
## Preheader
`preheader` sets the preview line that inbox lists show after the subject.
```html
```
---
# Spam protection
Source: https://formthunder.com/docs/spam
FormThunder stops form spam in layers: a honeypot field and a content filter on every plan, hCaptcha on every plan, and Turnstile, reCAPTCHA v3, your own captcha keys and domain restriction on Pro and Agency. Anything flagged goes to the spam folder in your dashboard, where you can restore it. It doesn't reach your inbox unless you do.
## Honeypot
Add a field that people never see. A bot that fills it gets a normal-looking success reply, so it learns nothing, and the submission goes to spam.
```html
```
## Content filter
Each submission gets a score from signals such as link count and spam phrases. A score of 5 or more goes to spam. The filter is on by default, and you can switch it off with `filter: false` in the form's `spam` settings.
## hCaptcha
Available on every plan, with FormThunder's own site key. Turn it on, then add the widget and our helper script.
```html
```
```bash
curl -X PATCH https://formthunder.com/api/v1/forms/YOUR_FORM_ID \
-H "Authorization: Bearer ft_live_..." \
-H "Content-Type: application/json" \
-d '{"spam":{"captcha":"hcaptcha","filter":true}}'
```
## Turnstile
Pro. Create a widget in Cloudflare, then store its site key and secret on the form. The secret stays on our side.
```html
```
```json
{ "spam": { "captcha": "turnstile", "site_key": "YOUR_SITE_KEY", "secret": "YOUR_SECRET", "filter": true } }
```
## reCAPTCHA v3
Pro. Run reCAPTCHA in your own page script and send the token as `g-recaptcha-response` or `recaptcha_response`. A score under 0.5 is refused. Change that with `min_recaptcha_score`.
```json
{ "spam": { "captcha": "recaptcha", "site_key": "YOUR_SITE_KEY", "secret": "YOUR_SECRET", "min_recaptcha_score": 0.5, "filter": true } }
```
Once a form has a captcha set, a post without a valid token gets a 400.
## Domain restriction
Pro. Accept posts only from your own domains. Subdomains match, and `www.` is ignored. A request with no `Origin` or `Referer`, such as one from a server, is refused.
```json
{ "allowed_domains": ["example.com"] }
```
## Report spam
Each notification email has a report spam link. One click blocks that sender on that form. In your own templates, use `{{report_spam_url}}`.
## Rate limit
FormThunder allows 30 requests a minute per IP address per form. Further requests get a 429 with `Retry-After`.
## Restoring a false positive
Open the spam folder in the dashboard and restore the submission. It's then delivered as normal.
---
# Attachments and uploader
Source: https://formthunder.com/docs/attachments
FormThunder accepts files in two ways, both on Pro and Agency, and only on forms with attachments turned on. A direct multipart upload handles files up to 5 MB each, in a request up to 6 MB. The advanced uploader sends files to storage first, which allows up to 25 MB per file. Files are attached to the email and linked from the dashboard.
## Turn attachments on
Set `attachments_enabled` on the form in the dashboard, or through your AI, the CLI or the API. Otherwise, files are ignored and the response carries a warning. The rest of the submission still arrives.
## Direct upload
```html
```
With `fetch`, pass a `FormData` object and leave out the `Content-Type` header, so the browser can set the boundary.
```js
await fetch("https://formthunder.com/submit", { method: "POST", body: new FormData(form) });
```
## Advanced uploader
The helper script turns a file input into a multi-file uploader.
```html
```
| Attribute | What it does |
|---|---|
| `data-advanced` | Set to `true` to turn the input into the uploader. |
| `data-form-id` | The form's key. It's read from the form's `access_key` if you leave it out. |
| `data-max-files` | How many files. Defaults to 1. |
| `data-max-file-size` | Size per file, such as `25mb`. The limit is 25 MB. |
| `data-content` | Text shown in the drop area. |
| `accept` | File types offered in the picker. |
## Uploading from your own code
Ask for a signed URL, PUT the file there, then submit its ID in the `attachment` field.
```js
const sign = await fetch("https://formthunder.com/api/uploads/sign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access_key: key, filename: file.name, size: file.size, type: file.type }),
}).then((r) => r.json());
await fetch(sign.url, { method: sign.method, headers: sign.headers, body: file });
await fetch("https://formthunder.com/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access_key: key, email, attachment: [sign.upload_id] }),
});
```
## How files reach you
Files are attached to the notification email up to a total size. Past that, the email links to them instead. Webhook payloads list each file's name, size, type and a download URL.
Treat files from a public form with the same care as any email attachment.
---
# Email templates and brand kit
Source: https://formthunder.com/docs/email-templates
FormThunder emails can match your site in three ways. The brand kit holds your logo, colours and fonts, and every built-in theme picks it up. Three themes work on every plan, and two more come with Pro. For full control, custom HTML templates use mustache variables, also on Pro. Your AI can read your website and set all of this up.
## Brand kit
```bash
# Read a website's branding
npx formthunder brand pull https://example.com
# Save a brand kit from a file
npx formthunder brand set --file brand.json
```
```json
{
"name": "Acme",
"logo_url": "https://example.com/logo.png",
"colors": {
"primary": "#0f766e",
"background": "#f8fafc",
"surface": "#ffffff",
"text": "#0f172a",
"muted": "#64748b",
"border": "#e2e8f0",
"button_text": "#ffffff"
},
"font_family": "Inter, Arial, sans-serif",
"heading_font_family": "Inter, Arial, sans-serif",
"border_radius": "8px",
"footer_text": "Acme Ltd",
"website_url": "https://example.com"
}
```
Use a PNG for the logo at an absolute `https` URL, because many email clients won't show SVG. Give font stacks email-safe fallbacks.
`POST /api/v1/brand/extract` returns `{brand, confidence, notes, applied}`. It only sees the served page, so check its output against your CSS or Tailwind config if you can. An AI agent with your code open is told to do this.
## Built-in themes
| Theme | Plan |
|---|---|
| `clean` | All plans |
| `card` | All plans |
| `letter` | All plans |
| `bold` | Pro and Agency |
| `compact` | Pro and Agency |
```bash
npx formthunder themes
npx formthunder forms update YOUR_FORM_ID --theme card
```
## Custom HTML templates
Pro. Write HTML with [mustache variables](/docs/variables). Use tables and inline CSS, as email clients ignore most modern CSS, and keep the width near 600px. Include a loop over the fields so nothing goes missing when the form changes.
```html
```
```bash
npx formthunder template preview template.html
npx formthunder template push template.html --name "Branded notification" --kind notification
npx formthunder template test YOUR_TEMPLATE_ID --to you@example.com
npx formthunder forms update YOUR_FORM_ID --template YOUR_TEMPLATE_ID
```
Preview returns warnings. Fix each one before you save. `{{{raw}}}` output is stripped from custom templates, so all values stay HTML-escaped.
## Generate one
`generate_template` writes a template from your brand kit and an optional URL. The response's `generator` field says `claude` if AI wrote it, or `theme` if we used a brand-themed fallback because no AI key was configured.
```bash
npx formthunder template generate --url https://example.com --out template.html
```
## The prompt that does it all
The MCP server ships a prompt named `match_website_branding`. Outside MCP, this prints the same instructions to paste into any AI.
```bash
npx formthunder prompt branding https://example.com
```
---
# Template variables
Source: https://formthunder.com/docs/variables
Template variables are mustache tags that FormThunder fills in when it sends an email. All values are HTML-escaped, and triple-brace `{{{raw}}}` output is stripped from custom templates. You can use them in custom HTML templates, and subject lines can also use any submitted field by name, such as `{{name}}`. Your AI can fetch this list with `list_template_variables`.
## Submission
| Variable | What it holds |
|---|---|
| `{{subject}}` | The email subject |
| `{{form_name}}` | Name of the form |
| `{{submitted_at}}` | When it was submitted, in UTC |
| `{{site_url}}` | Page the form was submitted from |
| `{{sender_email}}` | The visitor's email, if they gave one |
| `{{intro_text}}` | Intro text set on the form |
| `{{report_spam_url}}` | One-click link that blocks this sender on this form. Notification emails only |
| `{{dashboard_url}}` | Link to this submission in the dashboard. Notification emails only |
| `{{powered_by}}` | The Powered by line. Empty on Agency and client accounts |
## Fields
Loop over everything that was submitted. This keeps working when you add a field to the form.
```html
{{#fields}}
{{label}}: {{value}}
{{/fields}}
```
Or pick one field by name.
```html
Message: {{field.message}}
```
A top-level `{{message}}` works too.
## Attachments
```html
{{#attachments}}
{{filename}} ({{size}})
{{/attachments}}
```
## Brand
| Variable | What it holds |
|---|---|
| `{{brand.name}}` | Brand name |
| `{{brand.logo_url}}` | Logo URL |
| `{{brand.website_url}}` | Website URL |
| `{{brand.footer_text}}` | Footer text |
| `{{brand.colors.primary}}` | Primary colour. Also `background`, `surface`, `text`, `muted`, `border` and `button_text` |
| `{{brand.font_family}}` | Body font stack, with email-safe fallbacks |
| `{{brand.heading_font_family}}` | Heading font stack |
| `{{brand.border_radius}}` | Corner radius, such as `8px` |
## Subject lines
```text
New message from {{name}} about {{topic}}
```
If a field is missing, its tag renders empty, so keep the subject readable without it.
---
# Autoresponder
Source: https://formthunder.com/docs/autoresponder
An autoresponder sends an automatic reply to the person who submitted your form. It's on the Pro and Agency plans. Because it mails an address a stranger typed, it has two safeguards: the form must have allowed domains set and the post must come from one of them, and each address gets at most 3 autoresponses a day.
## Requirements
- The form has a field named `email`.
- `allowed_domains` is set on the form, and the post comes from one of those domains.
- The plan is Pro or Agency.
If a rule isn't met, the autoresponse shows as a skipped delivery with the reason. The notification email still goes out.
## Set it up
```bash
curl -X PATCH https://formthunder.com/api/v1/forms/YOUR_FORM_ID \
-H "Authorization: Bearer ft_live_..." \
-H "Content-Type: application/json" \
-d '{
"allowed_domains": ["example.com"],
"autoresponder": {
"enabled": true,
"from_name": "Acme",
"subject": "We got your message",
"intro_text": "Thanks for writing. We reply within one working day.",
"show_copy": true
}
}'
```
## Settings
| Setting | What it does |
|---|---|
| `enabled` | Turns it on. |
| `from_name` | Sender name. |
| `subject` | Subject line. Supports field variables such as `{{name}}`. |
| `intro_text` | The message above the copy of the submission. |
| `show_copy` | Includes a copy of what they sent. |
| `logo_url` | Logo at the top. |
| `theme` | A built-in theme. |
| `template_id` | A custom template of kind `autoresponder`. |
Send the whole `autoresponder` object when you change it: read it with `GET /api/v1/forms/:id`, change what you need and send it back.
## Custom template
```bash
npx formthunder template push reply.html --name "Thanks" --kind autoresponder
```
Then set `autoresponder.template_id` to the new ID. The variables are the same as in notifications, apart from `{{report_spam_url}}` and `{{dashboard_url}}`.
## Test it
`test_form` runs through the same settings. A post from a page on one of your allowed domains is the real test, because that's the check an autoresponder depends on.
---
# Webhooks
Source: https://formthunder.com/docs/webhooks
A FormThunder webhook is an HTTPS POST of JSON to your URL for each form submission, signed with HMAC-SHA256. Anything other than a 2xx reply retries with exponential backoff for up to 24 hours across 10 attempts, and each attempt's status and last error are visible. Webhooks are on the Pro and Agency plans.
## Set one up
```bash
curl -X PATCH https://formthunder.com/api/v1/forms/YOUR_FORM_ID \
-H "Authorization: Bearer ft_live_..." \
-H "Content-Type: application/json" \
-d '{"integrations":{"webhook":{"enabled":true,"url":"https://example.com/hooks/forms"}}}'
```
FormThunder generates a signing secret starting with `whsec_` the first time you set a webhook. Copy it from the form's settings. The `integrations` object is sent whole, so read the form first if it already has other integrations.
You can also send one submission to a URL of its own with a hidden field. It's signed only if the form also has a webhook set, since the secret belongs to that webhook.
```html
```
## Payload
```json
{
"event": "submission.created",
"submission_id": "8f14e45f-...",
"form_id": "3f2a7c10-...",
"form_name": "Contact form",
"submitted_at": "2026-11-05T09:30:00.000Z",
"data": { "name": "Priya Raman", "email": "priya@example.com", "message": "Hi" },
"subject": "New message from the website",
"from_name": "Acme website",
"site_url": "https://example.com/contact",
"attachments": [
{ "filename": "brief.pdf", "size": 48213, "content_type": "application/pdf", "url": "https://formthunder.com/files/..." }
]
}
```
## Headers
| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `FormThunder/1.0` |
| `X-FormThunder-Signature` | `t=,v1=` |
| `X-FormThunder-Delivery` | The delivery's ID. It stays the same across retries, so use it to ignore duplicates. |
## Verify the signature
The signature is HMAC-SHA256 of `.`, using your signing secret. Compute it over the raw bytes you received, before any JSON parsing. Reject old timestamps to block replays.
```js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyFormThunder(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const expected = createHmac("sha256", secret).update(parts.t + "." + rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
return Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds;
}
```
```python
import hashlib, hmac, time
def verify_formthunder(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
return False
return abs(time.time() - int(parts["t"])) <= tolerance
```
In a Next.js route handler, read the body with `await request.text()` and parse it after verifying.
```ts
export async function POST(request: Request) {
const raw = await request.text();
const header = request.headers.get("x-formthunder-signature") ?? "";
if (!verifyFormThunder(raw, header, process.env.FORMTHUNDER_WEBHOOK_SECRET!)) {
return new Response("Bad signature", { status: 401 });
}
const event = JSON.parse(raw);
// Store event.submission_id, then do slower work after replying.
return new Response("ok");
}
```
## Retries
| Attempt | Waits before it |
|---|---|
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
| 6 | 1 hour |
| 7 | 2 hours |
| 8 | 4 hours |
| 9 | 8 hours |
| 10 | 8 hours |
A request times out after 30 seconds. A 410 Gone reply stops retries for that delivery straight away. So does a URL that isn't public HTTPS, because we refuse private addresses.
## Replay
Failed deliveries can be sent again. Press resend on the submission, or use one of these.
```bash
npx formthunder submissions resend YOUR_SUBMISSION_ID
```
```bash
curl -X POST https://formthunder.com/api/v1/submissions/YOUR_SUBMISSION_ID/resend \
-H "Authorization: Bearer ft_live_..."
```
---
# Integrations
Source: https://formthunder.com/docs/integrations
FormThunder delivers form submissions to Slack, Discord, Telegram and Google Sheets directly, and to Zapier, Make, n8n or your own server through signed webhooks. Integrations are on the Pro and Agency plans. Each one is its own delivery, with its own status and retries, so a Slack outage doesn't affect the email.
## Settings
All of them live in the form's `integrations` object.
```json
{
"integrations": {
"webhook": { "enabled": true, "url": "https://example.com/hooks/forms" },
"slack": { "enabled": true, "url": "https://hooks.slack.com/services/..." },
"discord": { "enabled": true, "url": "https://discord.com/api/webhooks/..." },
"telegram": { "enabled": true, "chat_id": "-1001234567890" },
"sheets": { "enabled": true, "url": "https://script.google.com/macros/s/.../exec" }
}
}
```
The object is sent whole, so read it with `GET /api/v1/forms/:id` first and send it back with your change. Your AI's `get_form` and `update_form` tools are told to do this.
## Setup guides
| Integration | How it connects | Guide |
|---|---|---|
| Slack | Incoming webhook URL | [Slack](/integrations/slack) |
| Discord | Channel webhook URL | [Discord](/integrations/discord) |
| Telegram | FormThunder's bot and your chat ID | [Telegram](/integrations/telegram) |
| Google Sheets | An Apps Script web app you deploy | [Google Sheets](/integrations/google-sheets) |
| Your server | Signed webhook | [Webhooks](/docs/webhooks) |
| Zapier | Webhook to a Catch Hook trigger | [Zapier](/integrations/zapier) |
| Make | Webhook to a Custom webhook module | [Make](/integrations/make) |
| n8n | Webhook to a Webhook node | [n8n](/integrations/n8n) |
## Checking a delivery
Every submission lists its deliveries by channel, with status, attempts and last error. `test_form` and `npx formthunder test YOUR_FORM_ID` run a real submission through all of them and report back.
```bash
npx formthunder test YOUR_FORM_ID
```
The test command exits 1 if a delivery failed, so you can use it in CI.
---
# MCP server
Source: https://formthunder.com/docs/mcp
The FormThunder MCP server lets an AI agent configure your forms. It runs locally through `npx formthunder mcp`, or you can connect to the hosted endpoint at `https://formthunder.com/api/mcp`. Agents can create forms, write the code into your project, match emails to your brand and send real tests. MCP is included on every plan, Free too.
## Claude Code
```bash
claude mcp add formthunder -- npx -y formthunder mcp
```
The first run needs a key. Log in from a terminal, or set `FORMTHUNDER_API_KEY`.
```bash
npx formthunder login
```
For the whole run with the prompt to use, read [how to let Claude Code set up a contact form with MCP](/blog/claude-code-contact-form-mcp).
## Cursor and other clients
Most clients take a JSON config. In Cursor, that's `.cursor/mcp.json`.
```json
{
"mcpServers": {
"formthunder": {
"command": "npx",
"args": ["-y", "formthunder", "mcp"],
"env": { "FORMTHUNDER_API_KEY": "ft_live_..." }
}
}
}
```
Keep that file out of git if it holds a key.
## Hosted endpoint
The hosted server speaks Streamable HTTP and is stateless. Authenticate with an API key as a bearer token.
```bash
claude mcp add --transport http formthunder https://formthunder.com/api/mcp \
--header "Authorization: Bearer ft_live_..."
```
## Tools
See the [full list of tools](/ai#tools). The usual sequence is below, and the server gives agents the same instructions.
| Job | Tools, in order |
|---|---|
| Add a form to a site | `list_linked_emails`, `create_form`, `get_snippet`, `test_form`, `check_form_on_site` |
| Match emails to a site | `extract_brand_from_website`, `set_brand`, `list_template_variables`, `generate_template`, `preview_template`, `send_test_template`, `update_form` |
| Work on a client | `list_clients`, then `account_id` on any tool |
## Prompts
| Prompt | What it does |
|---|---|
| `setup_contact_form` | Creates a form, puts it in the site, sends a real test and checks the live page. |
| `match_website_branding` | Reads a site's branding, saves the brand kit, builds a matching template, tests it and attaches it. |
## Errors an agent can act on
Every error has a `hint` that says what to do next. Many results also carry a `next_step` field.
```json
{
"error": {
"code": "feature_requires_pro",
"message": "Webhooks need the Pro plan.",
"hint": "Upgrade at https://formthunder.com/app/billing or run `formthunder billing upgrade`.",
"docs": "https://formthunder.com/docs/webhooks"
}
}
```
## Keeping a human in the loop
Tools that delete data or cost money carry annotations and descriptions that tell the agent to confirm with you first. Your MCP client can also ask before each tool call. `get_audit_log` shows what changed and which key changed it, so you can review a session afterwards.
Only you can click an email verification link, so an agent has to ask you to do that when it adds a new recipient.
## Other machine-readable files
- [/llms.txt](/llms.txt) indexes these pages.
- [/llms-full.txt](/llms-full.txt) has the docs as Markdown.
- `/openapi.json` describes the REST API.
---
# CLI
Source: https://formthunder.com/docs/cli
The FormThunder CLI is an npm package you can run with `npx formthunder`. It does what the dashboard does, from forms and brand kits to templates, tests and clients, and any command accepts `--json` for scripts and AI agents. The same package runs the MCP server. It's included on every plan.
## Log in
```bash
npx formthunder login
```
This opens your browser to approve the device. In CI, set `FORMTHUNDER_API_KEY` or use `login --key`. Run `npx formthunder whoami` to see which account and plan you're on.
## Start a project
`init` detects your framework, creates a form and writes its snippet to stdout.
```bash
npx formthunder init
```
## Commands
| Command | What it does |
|---|---|
| `login`, `logout`, `whoami` | Manage the saved key. |
| `init` | Detect the framework, create a form and print its snippet. |
| `forms list`, `create`, `get`, `update`, `delete` | Manage forms. |
| `snippet` | Print form code for a framework. |
| `test` | Send a real test. Exits 1 if a delivery failed. |
| `check` | Check a live page is wired up. Exits 1 if a check fails. |
| `submissions list`, `get`, `resend` | Read submissions and re-queue failed deliveries. List and get need Pro. |
| `emails list`, `add`, `remove` | Manage linked emails. |
| `brand get`, `set`, `pull` | Manage the brand kit, or read it from a website. |
| `template list`, `push`, `pull`, `preview`, `test`, `generate` | Manage email templates. |
| `themes` | List built-in email themes. |
| `clients list`, `create`, `upgrade`, `downgrade`, `invite` | Manage client accounts. Agency. |
| `billing set`, `get`, `cancel` | Manage a client's retainer through your Stripe. Agency. |
| `billing upgrade` | Open the billing page to change your own plan. |
| `keys list`, `create`, `revoke` | Manage API keys. |
| `use` | Make a client account the default for later commands. Agency. |
| `mcp` | Start the stdio MCP server, for AI tools to launch. |
| `prompt branding` | Print a prompt to paste into any AI, to match emails to your site. |
Add `--help` to any command for its options.
## Use it in CI
Both of these exit 1 on failure, so a broken form can fail a deploy.
```yaml
- name: Check the contact form
run: |
npx formthunder test "$FORM_ID"
npx formthunder check "$FORM_ID" --url https://example.com/contact
env:
FORMTHUNDER_API_KEY: ${{ secrets.FORMTHUNDER_API_KEY }}
FORM_ID: ${{ vars.FORMTHUNDER_FORM_ID }}
```
`test` sends a real email to your recipients, so run it where that's welcome.
## Environment
| Variable | What it does |
|---|---|
| `FORMTHUNDER_API_KEY` | The key to use. Overrides the saved one. |
| `FORMTHUNDER_URL` | The API host, if you're not using the default. |
---
# REST API reference
Source: https://formthunder.com/docs/api
The FormThunder REST API lives at `https://formthunder.com/api/v1` and authenticates with a bearer key starting with `ft_live_`. It covers everything you can do in the dashboard. Errors always have the same shape, with a `hint` that says what to do next. The MCP server and CLI are built on this API, and `/openapi.json` describes it for tools.
## Authentication
```bash
curl https://formthunder.com/api/v1/me -H "Authorization: Bearer ft_live_..."
```
Create keys in the dashboard or with `npx formthunder keys create`. A key is shown once. It's a secret, unlike a form's access key, which is public.
Agency keys can act on a client by adding a header.
```text
X-FormThunder-Account:
```
## Errors
```json
{
"error": {
"code": "feature_requires_pro",
"message": "Webhooks need the Pro plan.",
"hint": "Upgrade at https://formthunder.com/app/billing or run `formthunder billing upgrade`.",
"docs": "https://formthunder.com/docs/webhooks"
}
}
```
## Account
| Endpoint | What it does |
|---|---|
| `GET /me` | Account, plan, limits, usage, role and kind |
| `GET /usage` | Submissions used this month |
| `GET /audit` | What changed, and which person or key changed it |
## Forms
| Endpoint | What it does |
|---|---|
| `GET /forms` | List forms. `?include_deleted=false` |
| `POST /forms` | Create: `{name, recipients[], subject?, from_name?, redirect_url?, ...}` |
| `GET /forms/:id` | Get a form |
| `PATCH /forms/:id` | Change any form setting |
| `DELETE /forms/:id` | Delete a form |
| `GET /forms/:id/snippet` | `?framework=html\|react\|nextjs\|vue\|svelte\|astro` returns `{framework, code, notes}` |
| `POST /forms/:id/test` | Run a real test. Returns `{submission_id, deliveries[]}` |
| `POST /forms/:id/check` | `{url}`: fetch the live page and return `{ok, checks:[{id, ok, message, fix}]}` |
Form settings include `subject`, `from_name`, `reply_to`, `redirect_url`, `cc`, `intro_text`, `theme`, `template_id`, `allowed_domains`, `spam`, `autoresponder`, `integrations`, `attachments_enabled`, `status`, `name` and `recipients`.
## Submissions
| Endpoint | What it does |
|---|---|
| `GET /submissions` | `?form_id&limit&cursor` returns `{data[], has_more, next_cursor}`. Pro |
| `GET /submissions/:id` | Includes `deliveries[]`. Pro |
| `POST /submissions` | `{form_id, fields{}, subject?, from_name?, replyto?, cc?, metadata?}` returns 201. Pro |
| `POST /submissions/:id/resend` | Re-queue failed deliveries |
## Brand and themes
| Endpoint | What it does |
|---|---|
| `GET /brand` | The account's brand kit |
| `PUT /brand` | `{name, logo_url, colors{...}, font_family, heading_font_family, border_radius, footer_text, website_url}` |
| `POST /brand/extract` | `{url, apply?}` returns `{brand, confidence, notes[], applied}` |
| `GET /themes` | Built-in themes |
## Templates
| Endpoint | What it does |
|---|---|
| `GET /templates` | List templates |
| `POST /templates` | `{name, kind, subject, html, text?}`. `kind` is `notification` or `autoresponder`. Custom HTML needs Pro |
| `GET /templates/:id` | Get a template |
| `PATCH /templates/:id` | Change a template |
| `DELETE /templates/:id` | Delete a template |
| `POST /templates/preview` | `{html?, template_id?, theme?, subject?, kind?, sample?}` returns `{subject, html, text, warnings[]}` |
| `POST /templates/test` | `{to, html?, template_id?, theme?, subject?, kind?}` returns `{sent:true}` |
| `POST /templates/generate` | `{url?, instructions?, kind?, save?, name?}`. The `generator` field says `claude` or `theme` |
| `GET /variables` | Every template variable |
## Linked emails
| Endpoint | What it does |
|---|---|
| `GET /emails` | Linked emails and whether each is verified |
| `POST /emails` | `{email}` sends a verification link |
| `DELETE /emails/:email` | Remove a linked email |
## Clients (Agency)
| Endpoint | What it does |
|---|---|
| `GET /clients` | List client accounts |
| `POST /clients` | `{name, contact_email?, invite?}` |
| `GET /clients/:id` | Get a client |
| `PATCH /clients/:id` | `{name, contact_email}` |
| `DELETE /clients/:id` | Delete a client and its forms |
| `POST /clients/:id/upgrade` | Upgrade to Pro. Adds a $5/mo line to your subscription |
| `POST /clients/:id/downgrade` | Move back to Free |
| `POST /clients/:id/invite` | `{email, role?}` |
| `GET /clients/:id/billing` | Retainer state |
| `POST /clients/:id/billing` | `{amount_cents, currency, interval, description, auto_upgrade?}` returns `{checkout_url, ...}` |
| `DELETE /clients/:id/billing` | Cancel the retainer |
## API keys
| Endpoint | What it does |
|---|---|
| `GET /api-keys` | List keys. Secrets aren't shown |
| `POST /api-keys` | `{name, scopes?}`. The key is shown once |
| `DELETE /api-keys/:id` | Revoke a key |
## Other endpoints
| Endpoint | What it does |
|---|---|
| `POST /submit` | [Public form ingest](/docs/submit-api) |
| `POST /api/uploads/sign` | [Signed upload URL](/docs/attachments) |
| `POST /api/mcp` | [Hosted MCP server](/docs/mcp) |
| `POST /api/quick-key` | `{email, name?}` creates an account and first form, then emails the key |
| `POST /api/cli/device` | Start a CLI device login |
| `POST /api/cli/token` | `{device_code}` returns `{status}`, plus the key once approved |
| `GET /api/health` | Health check |
| `GET /openapi.json` | OpenAPI description |
---
# Agency accounts
Source: https://formthunder.com/docs/agencies
An agency account manages client accounts from one login. Client accounts are unlimited and free, you can upgrade any client to Pro for $5 a month on your own bill, and you can charge clients a retainer through your own Stripe account. Everything works from the dashboard, the API, the CLI and MCP. It's on the Agency plan, at $39 a month or $390 a year.
## Create a client
```bash
npx formthunder clients create --name "Harbour Dental" --email owner@example.com --invite
```
```bash
curl -X POST https://formthunder.com/api/v1/clients \
-H "Authorization: Bearer ft_live_..." \
-H "Content-Type: application/json" \
-d '{"name":"Harbour Dental","contact_email":"owner@example.com","invite":true}'
```
Set `invite` to email the client an invitation. They can then log in to their own dashboard.
## Work inside a client account
An agency key acts on a client in three ways. In the CLI, `use` sets a default.
```bash
npx formthunder use CLIENT_ACCOUNT_ID
```
Over the API, send a header.
```text
X-FormThunder-Account: CLIENT_ACCOUNT_ID
```
Over MCP, every tool accepts an `account_id`. Your AI can list clients, then create forms and set brand kits in the right account.
```text
Create a client account for Harbour Dental, add a contact form to their site and match their brand.
```
## Limits
Your agency's own forms have 20,000 submissions a month. A client on the free tier has the Free plan's 250, and an upgraded client has Pro's 10,000. Client accounts don't show the "Powered by" line.
## Upgrade a client
An upgrade costs $5 a month and is added to your agency subscription. Downgrading removes the line.
```bash
npx formthunder clients upgrade CLIENT_ACCOUNT_ID
npx formthunder clients downgrade CLIENT_ACCOUNT_ID
```
## Bill a client through your Stripe
Connect your Stripe account from the dashboard, using Stripe Connect. Your clients pay your own Stripe account, and you're the merchant of record.
Agencies can connect from 45 countries, including the US, UK, Canada, Australia, New Zealand, Singapore, Hong Kong, Japan, Malaysia, Thailand, the Philippines, the UAE, Mexico, Brazil and most of Europe. Stripe can't connect agencies in India, Indonesia, Ghana, Kenya, Nigeria or South Africa to FormThunder right now. Agencies there can still use FormThunder and pay for it by card, but can't bill clients through it.
Then set a retainer. It returns a checkout link for you to send to the client, and nothing is charged until they complete it.
```bash
curl -X POST https://formthunder.com/api/v1/clients/CLIENT_ACCOUNT_ID/billing \
-H "Authorization: Bearer ft_live_..." \
-H "Content-Type: application/json" \
-d '{"amount_cents":2900,"currency":"usd","interval":"month","description":"Website care plan","auto_upgrade":true}'
```
`auto_upgrade` defaults to true. It moves the client to Pro once the retainer is active, and back to Free if the retainer is cancelled. Set it to false to manage the client's plan yourself. Retainer status is `pending`, `active`, `past_due` or `canceled`.
To bill by emailed invoice instead of a card link, add `"collection":"invoice"`. Stripe emails the client an invoice each period, due in 14 days unless you set `days_until_due`, and the retainer goes active when the first one is paid.
## Send a one-off invoice
For something outside the retainer, like a setup fee, send an invoice from your Stripe account. Stripe emails it to the client's contact email straight away and hosts the payment page.
```bash
npx formthunder invoices create CLIENT_ACCOUNT_ID --amount 25000 --description "Contact page setup"
```
Or use the API:
```bash
curl -X POST https://formthunder.com/api/v1/clients/CLIENT_ACCOUNT_ID/invoices \
-H "Authorization: Bearer ft_live_..." \
-H "Content-Type: application/json" \
-d '{"currency":"usd","days_until_due":14,"items":[{"description":"Contact page setup","amount_cents":25000}]}'
```
`GET` on the same URL lists them, with status `open`, `paid`, `void` or `uncollectible`. `DELETE .../invoices/INVOICE_ID` voids an open one. Refund a paid invoice in your own Stripe dashboard.
## Roles
Invite people to a client account with a role of `owner`, `admin`, `member` or `viewer`.
```bash
npx formthunder clients invite CLIENT_ACCOUNT_ID --email owner@example.com --role member
```
---
# FAQ
Source: https://formthunder.com/docs/faq
These are short answers to the questions people ask most about FormThunder: whether the access key is safe in public HTML, what happens when you pass your limit, how long submissions are kept, and how FormThunder relates to Web3Forms. For fixing something that's broken right now, see [troubleshooting](/docs/troubleshooting).
## Is the access key safe in my HTML?
Yes. It's public by design and can only send to addresses you've verified. Someone who copies it can post to your form, which is the same as filling it in. Domain restriction on Pro limits where posts can come from. API keys starting with `ft_live_` are different: they're secret.
## What happens when I go over my monthly limit?
Submissions are stored and held. They're released and delivered when the account upgrades. You get an email at 80% of the limit and again when you reach it.
## How long are submissions kept?
The dashboard keeps 30 days of history on Free, and 1 year on Pro and Agency. You can export to CSV on every plan.
## Do I need AI to use FormThunder?
No. A plain HTML form works without any AI, and so do the dashboard and the API. MCP and the CLI are there if you want an agent to do the setup.
## Which AI tools work with it?
Anything that speaks MCP, such as Claude Code or Cursor, or anything that can run a command line, using `npx formthunder`.
## Is FormThunder the same as Web3Forms?
No. It's a separate product that accepts the same form fields, so a form can [move across](/migrate/web3forms) by changing its action URL and key.
## Can a form have several recipients?
Yes: 1 on Free, 5 on Pro and 10 on Agency. Each recipient must be a verified linked email.
## Can I use my own sending domain?
Not today. Emails come from FormThunder's sending address, with your chosen from name and reply-to.
## Do I need JavaScript?
No. A plain form post works, and our helper script is optional. It's only needed for captcha widgets and the advanced uploader.
---
# Troubleshooting
Source: https://formthunder.com/docs/troubleshooting
Most form problems show up on the submission itself, because FormThunder records each delivery's status, attempts and last error. Start in the dashboard, or ask your AI to run `get_submission` or `test_form`. If there's no submission at all, the request never arrived, so check the page with `check_form_on_site`. These are the common causes and fixes.
## No email arrived
1. Check the submission's email delivery. **Delivered** means your mail provider accepted it, so look in your spam folder.
2. **Skipped** usually means no recipient on the form is verified. Click the verification link we sent, or add an address with `add_linked_email`.
3. **Pending** means it's still retrying. **Failed** shows the last error, and you can resend it.
4. If the submission is in the spam folder, restore it, and check why it was flagged. A honeypot field that's visible and gets ticked will flag a real person.
5. If its status is held, the account is over its monthly limit. See below.
## 400: Missing or invalid access_key
The form has no `access_key` field, or the value isn't a form's ID. Check for typos and that the field's `name` is exactly `access_key`.
## 400: Captcha failed
The form has a captcha set, and the post had no valid token. Add the widget and `/client/script.js` to the page, or set `captcha` to `none` on the form. For Turnstile, check that the widget's hostname list includes your domain.
## 403: This form doesn't accept submissions from this website
The origin isn't on the form's `allowed_domains`. Add it. Preview deployments and `localhost` are separate hosts, and server-side posts have no origin at all.
## 403: This form isn't accepting submissions
The form is paused. Resume it in the dashboard.
## 413: Too large
Requests stop at 6 MB, and direct files at 5 MB. Use the [advanced uploader](/docs/attachments) for files up to 25 MB.
## 429: Too many requests
Over 30 requests a minute from one IP address to one form. If your own server is posting for all your visitors, that's the likely cause. Post from the browser, or use `POST /api/v1/submissions` with an API key.
## My files didn't arrive
Attachments need Pro, and `attachments_enabled` set on the form. Check the form has `enctype="multipart/form-data"`. The JSON response's warnings tell you if files were ignored.
## The autoresponder didn't send
The delivery shows as skipped, with a reason. It needs `allowed_domains` set, a post from one of them, and a field named `email`. Each address gets at most 3 a day. See [the autoresponder](/docs/autoresponder).
## A webhook keeps failing
Read the last error on the delivery. Your endpoint must be public HTTPS and reply with a 2xx within 30 seconds. After a fix, press resend, or run `resend_deliveries`. If the signature doesn't match, you're probably hashing parsed JSON rather than the raw body. See [webhooks](/docs/webhooks).
## My submissions show as held
The account passed its monthly limit. Nothing is lost: they're stored and released when the account upgrades.
## My AI says it can't verify an email
It can't. Verification needs someone to click a link in the inbox. Click it, then ask the agent to carry on.
## Still stuck?
Email support@formthunder.com with the submission ID, or the form ID and the page URL.
---