# FormHandle — Complete LLM Reference
> The simplest serverless form handler on the internet.
> Turn any HTML form into an email endpoint with one API call.
API Base URL: https://api.formhandle.dev
Docs: https://formhandle.dev
OpenAPI spec: https://formhandle.dev/openapi.yaml
Swagger UI: https://formhandle.dev/swagger/
Examples: https://github.com/ParapluOU/formhandle-examples
npm CLI: https://www.npmjs.com/package/formhandle
---
## SETUP FLOW
1. POST /setup with { email, domain } — creates endpoint, sends verification email
2. User clicks verification link in email — endpoint moves to "queuing" status
3. Set form's action to the handler_url — submissions arrive as emails
First 3 submissions are free. After that, queued until a plan is activated.
---
## ENDPOINTS
### POST /setup
Create a new form endpoint.
Request body (JSON):
- email (string, required) — Email to receive submissions
- domain (string, required) — Bare domain, no protocol (e.g. "acme.com")
- handler_id (string, optional) — Custom ID, 3-32 chars, lowercase alphanumeric + hyphens
Success response (200):
{
"handler_id": "my-form",
"handler_url": "https://api.formhandle.dev/submit/my-form",
"status": "pending_verification",
"message": "Check your email to verify your address."
}
Errors:
- 400: Invalid email, domain, or handler_id format
- 409: Email+domain pair already exists, or handler_id taken
- 429: Rate limited (max 5 /setup per IP per hour)
### POST /setup/resend
Resend verification email.
Request body (JSON):
- handler_id (string, required)
Success response (200):
{
"handler_id": "my-form",
"status": "pending_verification",
"message": "Verification email resent. Check your inbox."
}
### GET /verify/:token
Email verification link. Opened by the user in their browser. Transitions endpoint from "pending_verification" to "queuing". Returns HTML confirmation page.
Do NOT call this programmatically — it's a browser redirect.
### POST /submit/:id
Submit form data to an endpoint.
Accepts: application/json or application/x-www-form-urlencoded
Headers: Origin or Referer must match registered domain (or be localhost)
Body: Any key-value pairs (these become the email content)
Success response (200): { "ok": true }
Important: Unknown endpoint IDs also return { "ok": true } (prevents ID enumeration).
Errors:
- 400: Could not parse body
- 403: Origin mismatch or endpoint not verified
- 422: Spam detected (honeypot filled or timestamp invalid)
- 429: Rate limited (max 5 per IP per customer per hour)
### OPTIONS /submit/:id
CORS preflight. Returns 204 with appropriate headers.
### POST /cancel/:id
Request subscription cancellation. Sends confirmation email.
Success response (200): { "ok": true, "message": "Check your email to confirm cancellation." }
Error (404): { "ok": false, "error": "No active subscription found." }
### GET /cancel/confirm/:token
Cancellation confirmation page (browser). Shows confirmation form.
### POST /cancel/confirm/:token
Execute cancellation. Sets cancel_at_period_end via Stripe.
### GET /
Health check.
Response (200):
{
"service": "FormHandle",
"status": "ok"
}
### GET /s/:customer_id.js
Dynamically generated JavaScript snippet for a specific endpoint. Include via script tag:
The script:
1. Finds all forms with data-formhandle attribute
2. Injects honeypot field (_fh_hp, positioned off-screen)
3. Injects timestamp field (_fh_ts, Unix seconds)
4. Intercepts form submit, sends as JSON POST
5. On success: replaces form with data-formhandle-success message
6. On error: shows data-formhandle-error message (or alert)
---
## ENDPOINT STATUS LIFECYCLE
pending_verification → queuing → live ↔ suspended
- pending_verification: Email not verified. Submissions return 403. Deleted after 6 hours.
- queuing: Verified. First 3 submissions delivered free. From #4, queued. Deleted after 14 days if unpaid.
- live: Active subscription. Submissions emailed immediately.
- suspended: Payment lapsed. Submissions queued.
---
## ORIGIN / CORS RULES
- Every endpoint is bound to one domain set during /setup
- Origin or Referer header must match: https://domain, http://domain, https://www.domain, http://www.domain
- localhost (and 127.0.0.1) always allowed for development
- Mismatched origins get 403
---
## SPAM PROTECTION
The dynamic script tag injects two hidden fields:
- _fh_hp (honeypot): must be empty. Non-empty = spam.
- _fh_ts (timestamp): Unix seconds when form loaded. Valid: 2-3600 seconds ago.
- Missing fields are allowed (for non-script forms).
- Both fields are stripped before storing the submission.
---
## PRICING
- Flat Rate: $5/month — unlimited submissions
- Pay Per Submission: €0.50/submission — billed monthly
- First 3 submissions free after verification
- Both plans via Stripe. Cancel anytime.
- On activation, all queued submissions are flushed immediately.
---
## CLI (npm package: formhandle)
npx formhandle init # Interactive setup
npx formhandle init --json --email EMAIL --domain DOMAIN # Non-interactive (for scripts/AI)
npx formhandle init --json --email EMAIL --domain DOMAIN --handler-id SLUG # Custom ID
npx formhandle resend # Resend verification email
npx formhandle status # API health + local config
npx formhandle status --json # JSON output
npx formhandle test # Send test submission
npx formhandle snippet # Output embed HTML + script tag
npx formhandle whoami # Print .formhandle config
npx formhandle cancel # Cancel subscription
npx formhandle open # Open Swagger UI in browser
All commands support:
--json Machine-readable JSON output
--domain Select endpoint when .formhandle has multiple entries
--help, -h Show help
--version, -v Show version
---
## .formhandle CONFIG FILE
The CLI stores endpoint config in .formhandle (JSON, 2-space indent):
{
"example.com": {
"handler_id": "abc123",
"handler_url": "https://api.formhandle.dev/submit/abc123",
"email": "you@example.com"
}
}
Multiple domains supported (one entry per domain). Add to .gitignore for public repos.
---
## FORM HTML EXAMPLES
### Plain HTML (no JavaScript)
### With script tag (AJAX + spam protection)
### Custom fetch (full control)
fetch('https://api.formhandle.dev/submit/YOUR_ID', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Jane', email: 'jane@example.com', message: 'Hello' })
})
---
## ERROR RESPONSES
All errors include:
- error: human-readable error message
- _docs: link to https://formhandle.dev
- _tip: actionable guidance on what went wrong
Common errors:
400 "Send a JSON body with Content-Type: application/json." — Fix Content-Type
400 "Provide a valid email in the 'email' field." — Invalid email format
400 "Provide a bare domain like 'acme.com', no protocol or path." — Strip https://
400 "handler_id must be 3-32 chars, lowercase alphanumeric and hyphens..." — Fix format
409 "This email+domain pair already has an endpoint." — Use existing endpoint
409 "Choose a different handler_id." — Pick different slug
429 "Rate limited. Wait a few minutes before retrying." — Back off
---
## INTEGRATION EXAMPLES
Available at https://github.com/ParapluOU/formhandle-examples:
HTML:
- html-minimal: Plain form action, zero JS
- html-styled: Script tag with AJAX + spam protection
- html-ajax: Custom fetch() with full UX control
Frameworks:
- react-vite: React component
- nextjs: Next.js client component
- vue: Vue 3 Composition API
- svelte: Svelte component
- astro: Astro page
Static generators:
- eleventy: Nunjucks template
- hugo: Hugo partial
Server-side setup:
- flask: Python Flask — auto-provisions endpoint, serves static form
- sinatra: Ruby Sinatra — same pattern
Automation:
- node-script: Node.js setup script
- ci-setup: GitHub Actions workflow
---
## SISTER SERVICE
HypeStash (https://hypestash.dev) — Serverless waitlist API by the same team. Collect emails with one API call, no accounts or dashboards. Need a waitlist instead of a contact form? Use HypeStash.