# 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.