New here? Start with the Quickstart. Building against the API? Jump to The endpoint and Requests & responses. Everything on this page works on the free plan.
Introduction
splitforms is a form backend API. You point an HTML form at https://splitforms.com/api/submit, submissions arrive in your inbox, and the dashboard shows every entry with search, CSV export, spam filtering, and webhook fan-out. There is no server code to write, no database to run, and no email infrastructure to maintain.
It works with anything that can submit an HTML form or make an HTTP POST: static HTML, React, Next.js, Vue, Svelte, Astro, Hugo, Eleventy, Webflow, Framer, Carrd, WordPress, plain JavaScript, server actions, and edge functions.
Quickstart
Three steps from zero to a submission in your inbox — about a minute of work.
YOUR_ACCESS_KEY with yours, and paste it on any page:<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="text" name="name" placeholder="Your name" required />
<input type="email" name="email" placeholder="Your email" required />
<textarea name="message" placeholder="Message" required></textarea>
<!-- honeypot: bots fill this, humans never see it -->
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button type="submit">Send</button>
</form>The endpoint
There is a single endpoint. Learn it once and every form on every site you own uses the same thing.
| Property | Value |
|---|---|
| URL | https://splitforms.com/api/submit |
| Method | POST |
| Auth | access_key field (per form) |
| Body | form-urlencoded · multipart/form-data · JSON |
| CORS | open — browsers can POST from any origin |
| Response | JSON, a 302 redirect, or a success page |
A GET to the endpoint returns 405 (use POST), and an OPTIONS preflight returns 204 with permissive CORS headers, so browser fetches work without extra configuration. Any content type other than the three above returns 415.
Requests & responses
The response format follows your request. Ask for JSON and you get JSON; submit a native HTML form and you get a redirect or a success page.
Success
When the request sets Accept: application/json or Content-Type: application/json, a successful submit returns 200 with:
{
"success": true,
"message": "Submission received"
}Otherwise (a plain HTML form post) splitforms issues a 302redirect if you configured a redirect URL for the form, or renders a built-in "Submission received" page. See Redirects & thank-you pages.
Errors
Errors use the same shape with a machine-readable code so you can branch on it:
{
"success": false,
"message": "Missing access_key. Copy the form access key from your dashboard…",
"code": "missing_access_key"
}| Status | Meaning |
|---|---|
| 200 | Submission received (spam is silently accepted too) |
| 400 | Missing access_key or an unparseable body |
| 403 | Form inactive, or blocked by strict origin protection |
| 404 | Unknown access_key |
| 415 | Unsupported content type |
| 429 | Rate limited, or monthly quota reached |
| 500 | Server error — safe to retry |
Reserved fields
A handful of field names control behavior instead of being saved as submission data. Every other field you send is captured verbatim and shown in the dashboard and the notification email.
| Field | What it does |
|---|---|
| access_key | Identifies your form — required |
| subject | Overrides the notification email subject |
| from_name | Overrides the sender display name |
| replyto | Overrides Reply-To (defaults to the email field) |
| redirect | Reserved and ignored — set redirects in the dashboard |
| botcheck | Honeypot — must stay empty |
| form_loaded_at | Optional time-trap timestamp |
| g-recaptcha-response | reCAPTCHA v2 token (verified if enabled) |
| cf-turnstile-response | Turnstile token (accepted, stripped) |
Payload limits: up to 100 fields per submission and 10 KB per field value. Longer values are truncated rather than rejected.
Authentication
Submitting a form needs one string: the access_key. It identifies which form, inbox, and account a submission belongs to. Because it lives in client-side HTML, it is not a secret in the bearer-token sense — anyone who views your page source can read it. That is expected for a form backend; the protection lives at the endpoint (honeypot, spam scoring, rate limits, quotas), not the key.
Generate a key
Open Dashboard → Forms → New form. A fresh access key is created with every form, each with its own inbox, submissions list, subject, redirect, allowed domains, and auto-responder.
Lock it to your domains
Add an allowed-domain list on the form, and turn on Strict origin protection in Settings → Securityto hard-drop POSTs whose origin doesn't match (returns 403). Leave it off for server, mobile, cURL, or WordPress integrations that don't send a trustworthy origin.
Rotate a leaked key
If a key ends up somewhere public, open the form and click Rotate access key. The old key stops working immediately; existing submissions are untouched.
Read API token
To read submissions back out (GET /api/submissions), send your account token — the same token shown under Dashboard → MCP — as a bearer header. It scopes to your whole account and never appears in client-side code.
curl https://splitforms.com/api/submissions \
-H "Authorization: Bearer YOUR_API_TOKEN"cURL & servers
Because it is just an HTTP POST, you can submit from a terminal, a cron job, or any server runtime. Send form-encoded data:
curl -X POST https://splitforms.com/api/submit \
-d access_key=YOUR_ACCESS_KEY \
-d name="Ada Lovelace" \
-d email="ada@example.com" \
-d message="Hello from cURL"Or JSON, which is often cleaner from a backend:
curl -X POST https://splitforms.com/api/submit \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"access_key":"YOUR_ACCESS_KEY","name":"Ada","email":"ada@example.com","message":"Hi"}'HTML
The simplest integration — no JavaScript. The browser posts the form natively; splitforms redirects to your thank-you page (set in the dashboard) or shows a built-in success page.
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required></textarea>
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button type="submit">Send</button>
</form>JavaScript (fetch)
Submit with fetch for a no-reload experience and inline success/error states. Ask for JSON so you get a JSON body back instead of a redirect.
const form = document.querySelector("#contact");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form), // includes the hidden access_key input
});
const data = await res.json();
if (data.success) form.reset();
else console.error(data.code, data.message);
});React
Use fetch from a client component. The browser encodes the FormData for you; just inject the access key.
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
async function onSubmit(e) {
e.preventDefault();
setStatus("loading");
const formData = new FormData(e.target);
formData.append("access_key", import.meta.env.VITE_SPLITFORMS_KEY);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: formData,
});
const json = await res.json();
setStatus(json.success ? "ok" : "err");
if (json.success) e.target.reset();
}
return (
<form onSubmit={onSubmit}>
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style={{ display: "none" }} tabIndex={-1} />
<button type="submit">Send</button>
{status === "ok" && "Thanks!"}
{status === "err" && "Error"}
</form>
);
}Next.js (App Router)
Two patterns work. A server action keeps the key on the server and gives progressive enhancement for free; a client fetch gives you real-time UI.
Server action
// app/contact/actions.ts
"use server";
export async function sendContact(formData: FormData) {
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
access_key: process.env.SPLITFORMS_KEY!,
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
}),
cache: "no-store",
});
if (!res.ok) throw new Error("submission failed");
return { ok: true as const };
}// app/contact/page.tsx
import { sendContact } from "./actions";
export default function ContactPage() {
return (
<form action={sendContact}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}Client component fetch
"use client";
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState<"idle" | "loading" | "ok" | "err">("idle");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("loading");
const formData = new FormData(e.currentTarget);
formData.append("access_key", process.env.NEXT_PUBLIC_SPLITFORMS_KEY!);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: formData,
});
const data = await res.json();
setStatus(data.success ? "ok" : "err");
}
return (
<form onSubmit={handleSubmit}>
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style={{ display: "none" }} tabIndex={-1} />
<button type="submit">{status === "loading" ? "Sending…" : "Send"}</button>
{status === "ok" && <p>Thanks!</p>}
{status === "err" && <p>Something went wrong.</p>}
</form>
);
}Next.js (Pages Router)
Pages Router has no server actions — fetch from a component, or proxy through an API route to keep the key off the client entirely.
Direct from a page component
// pages/contact.tsx
import { useState } from "react";
export default function Contact() {
const [status, setStatus] = useState<"idle" | "ok" | "err">("idle");
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
fd.append("access_key", process.env.NEXT_PUBLIC_SPLITFORMS_KEY!);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: fd,
});
const json = await res.json();
setStatus(json.success ? "ok" : "err");
}
return (
<form onSubmit={onSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style={{ display: "none" }} tabIndex={-1} />
<button>Send</button>
{status === "ok" && "Thanks!"}
{status === "err" && "Error"}
</form>
);
}Through an API route (keeps the key server-side)
// pages/api/contact.ts
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end();
const r = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access_key: process.env.SPLITFORMS_KEY, ...req.body }),
});
res.status(r.status).json(await r.json());
}Vue / Nuxt
<script setup>
import { ref } from "vue";
const status = ref("idle");
async function onSubmit(e) {
status.value = "loading";
const formData = new FormData(e.target);
formData.append("access_key", import.meta.env.VITE_SPLITFORMS_KEY);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: formData,
});
const json = await res.json();
status.value = json.success ? "ok" : "err";
}
</script>
<template>
<form @submit.prevent="onSubmit">
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button>Send</button>
</form>
</template>Svelte / SvelteKit
In SvelteKit a form action gives progressive enhancement and keeps the key server-side. For plain Svelte, fetch from a component:
<script>
let status = "idle";
async function onSubmit(event) {
status = "loading";
const formData = new FormData(event.target);
formData.append("access_key", import.meta.env.VITE_SPLITFORMS_KEY);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: formData,
});
const json = await res.json();
status = json.success ? "ok" : "err";
}
</script>
<form on:submit|preventDefault={onSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button>Send</button>
</form>Static sites & no-code builders
Anything that outputs HTML or lets you set a form action can post directly to the endpoint.
Astro, Eleventy, Hugo, Jekyll
Static generators output plain HTML, so the form posts with zero JavaScript. Read the key from an environment variable at build time:
---
// src/pages/contact.astro
const ACCESS_KEY = import.meta.env.PUBLIC_SPLITFORMS_KEY;
---
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value={ACCESS_KEY} />
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button type="submit">Send</button>
</form>Webflow
In the Designer, set the form's Action to https://splitforms.com/api/submit and Method to POST. Add an Embed element inside the form with the hidden key (Webflow strips fields added at page level):
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />Give every Webflow input a name in its settings — splitforms uses field names as the keys in the email body.
Framer
Use Framer's Form component and set its URL to https://splitforms.com/api/submit with Method POST. Add a Hidden Field named access_key with your value.
Carrd
Add a Form element, set Type to Custom URL, point Action at https://splitforms.com/api/submit with Method POST, and add a Hidden Field named access_key. Set your thank-you redirect in the splitforms dashboard.
Handling submissions
The dashboard is the source of truth. Every accepted submission is written to the database before the API responds, so if you see the row, the data is safe — even if an email or webhook is delayed.
In the dashboard you can:
- Search and filter submissions across all your forms.
- Export any form's history to CSV in one click.
- Review a separate spam queue and recover false positives.
- Delete individual entries or clear a form's history.
Every submission gets a sequential Lead #N reference number. It appears in the dashboard inbox, the submission drawer, your notification emails, and the auto-responder — so you and the submitter can quote the same ID in follow-ups. Search for it in the inbox as 101 or #101.
Submission retention is 30 days on Free, 365 days on Pro and Business, and 1,460 days on the 3-Year plan. You can delete entries sooner from the dashboard.
Email notifications
Notification emails are sent from splitforms' own infrastructure with proper SPF, DKIM, and DMARC alignment, so they clear most spam filters without you touching DNS. Email notifications are free on every plan.
Reply goes to the visitor
If your form has an email field, splitforms sets Reply-To to it automatically — hitting Reply in your inbox responds to the person who submitted. Override it with a replyto field.
Sender name and subject
Set a from_name field to change the display name, and a subject field (or a per-form template with placeholders like {{name}}) to change the subject line.
Deliverability checklist
- Send notifications to a real, monitored mailbox.
- Safelist
@splitforms.comon strict corporate mail (Microsoft 365). - Use linked emails (BCC) for archiving, not forwarding rules.
- Avoid no-reply recipients — providers downrank unread inboxes.
Auto-responders & custom sending domain
The auto-responder (paid plans) sends an instant confirmation email to the submitter's email field on every clean submission — it never fires on submissions flagged as spam. Configure it in Settings → Auto-reply & form behavior.
Template variables
Personalize the reply with {{ variables }}: {{name}}, {{email}}, {{business}}, {{website}}, {{lead_number}}(the submission's sequential Lead #N reference), plus any field your form submits — {{message}}, {{phone}}, and so on.
Custom email design (HTML/CSS)
On paid plans you can replace the built-in design entirely: paste a full HTML template under Settings → Auto-reply & form behavior → Custom email design (HTML/CSS). A live sandboxed preview in settings renders it with sample data before you save, a plain-text fallback is always sent alongside, and leaving the field empty restores the clean default template. Templates are capped at 50KB.
- Inline CSS only — email clients strip
<style>blocks and ignore classes. - Tables for layout, not flexbox or grid (Outlook).
- Max-width 600px for the content column.
- Web-safe font stacks with fallbacks.
Send from your own domain (SMTP)
Also on paid plans: Settings → Email notifications → Send from your own domain (SMTP) routes every form email — auto-responders and owner notifications — through an SMTP server you control, so recipients see you@yourdomain.com.
- Enter host, port, SSL setting, username, and password, plus your From email and name. The password is stored AES-256-GCM encrypted and never shown back.
- Verification is required: the config only activates after a successful Send test email, and any change to the config resets verification.
- Automatic fallback: if your SMTP server fails, splitforms retries through its own infrastructure — no email is ever lost.
- SPF/DKIM/DMARC are your responsibility: add your provider's records in your DNS or mail from your domain will land in spam.
Provider-by-provider recipes (Gmail app passwords, Microsoft 365, ZeptoMail/Resend/SMTP2GO) are in the blog guide: send form emails from your own domain.
Spam filtering
Spam is handled server-side so you don't need a visible CAPTCHA. Several layers run on every submission; the honeypot is on by default and the rest are toggles in your form settings.
- Honeypot. The hidden
botcheckfield ships in every template. Bots fill it; humans don't. A filled honeypot is dropped silently — the bot still gets a200so it never learns to evade. - Time trap. An optional
form_loaded_attimestamp catches bots that submit instantly. Enabled by default when present. - Content scoring. Every submission is scored for spam patterns and disposable emails. Matches are quarantined in the spam queue (recoverable), not deleted.
- Rate limits. Each form is capped per IP — see Rate limits & quotas.
Optional reCAPTCHA
Add a reCAPTCHA v2 secret in Settings → Security and splitforms verifies the g-recaptcha-response token server-side, dropping submissions that fail. A Cloudflare Turnstile token (cf-turnstile-response) is accepted and stripped from saved data, but Turnstile is not yet verified server-side, so honeypot plus content scoring remain your primary defense there.
File uploads
Accept attachments by sending the form as multipart/form-data to the same endpoint — add a file input and let the browser set the encoding:
<form action="https://splitforms.com/api/submit" method="POST"
enctype="multipart/form-data">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="email" name="email" required />
<input type="file" name="resume" />
<button type="submit">Apply</button>
</form>Limits are 5 files per submission and 5 MB per file. Uploads require a paid plan with the Storage integration connected; without it the text fields still save and the JSON response notes that files were dropped. Stored files remain private, follow the parent submission's plan retention period, and appear through access-controlled download links in the dashboard, notification email, and webhook payloads. JSON submissions cannot carry files.
enctype, or fd.append(input.value)) uploads nothing. The JSON response includes a files.hint explaining the fix.Redirects & thank-you pages
Set a redirect URL per form in Dashboard → Form settings → Redirect. After a successful native form post, splitforms sends the visitor there with a 302.
redirect field submitted with the form is ignored — honoring it would turn the endpoint into an open-redirect hop for phishing. Configure redirects in the dashboard only.Prefer to stay on the page? Submit with fetch and Accept: application/json, then render your own success state from the JSON response — no redirect happens. If no redirect is set and the request isn't JSON, splitforms shows a built-in success page.
Form settings
Most behavior is configurable per form in the dashboard, or overridable per submission with a reserved field.
- Subject. A
subjectfield, or a per-form template with placeholders like{{name}}and{{email}}. - Sender name. A
from_namefield sets the display name on the notification. - Auto-responder.Send an automatic thank-you reply to the submitter's
email(paid). It does not fire on submissions flagged as spam. - Linked emails (BCC). BCC every submission across all your forms to a shared or teammate inbox for archival.
Webhooks
Push every submission to your own server, Slack, Discord, Telegram, or anything that accepts an HTTP POST. splitforms auto-detects Slack, Discord, and CallMeBot URLs and formats the payload for them; every other target receives the full submission JSON. Webhooks are available on paid plans and configured in Dashboard → Webhooks.
Payload
Generic targets receive this body with Content-Type: application/json and X-Splitforms-Event: submission.created:
{
"event": "submission.created",
"submission": {
"id": "3f9a2b7e-4c1d-4e9a-9b2f-7a6c1d3e4f5a",
"form_name": "Contact",
"data": {
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "Hello."
},
"ip_address": "203.0.113.7",
"referer": "https://yoursite.com/contact",
"created_at": "2026-05-04T14:31:04.123Z"
}
}Verify the signature
Each webhook has a shared secret. splitforms signs the raw body with HMAC-SHA256 and sends it in the X-Splitforms-Signature header as sha256=<hex>. Verify with a constant-time comparison:
import crypto from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader),
);
}
// Express — read the RAW body, not the parsed JSON.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.header("X-Splitforms-Signature");
if (!sig || !verify(req.body, sig, process.env.SPLITFORMS_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const { submission } = JSON.parse(req.body);
// …do work with submission
res.status(200).end();
});Timeouts & retries
Delivery times out after 8 seconds. splitforms does not auto-retry — instead the dashboard shows the last status and error per webhook. Respond 200 fast and do slow work asynchronously; for durable retries, point the webhook at a queue (Upstash QStash, Inngest, SQS). Use Test webhook to fire a sample payload.
Integrations
Beyond raw webhooks, splitforms delivers submissions to common tools directly. Connect these in Dashboard → Integrations (paid plans):
- Google Sheets. Append each submission as a row; new fields get their own column automatically.
- Notion & Airtable. Add each submission to a database for lightweight CRM.
- Slack & Discord. Post a formatted message to a channel via a webhook URL.
- Telegram, CallMeBot & Zapier. Instant chat pings, or fan out to thousands of apps through a Zapier webhook.
Step-by-step recipes live on the blog — Slack, Notion, Sheets, and Zapier.
Rate limits & quotas
Two independent limits protect your forms: a per-IP burst limit that fights abuse, and a monthly quota tied to your plan.
Per-IP rate limit
Each form accepts up to 6 submissions per IP per minute and 20 per IP per 15 minutes. Real users never hit it; scrapers hit it constantly and receive 429.
Monthly quota
| Plan | Price | Submissions / month |
|---|---|---|
| Free | $0 | 500 |
| Pro | $5 / mo | 5,000 |
| 3-Year | $59 / 3 yrs | 15,000 |
Forms are unlimited on every plan. Paid plans add webhooks, integrations, file uploads, the auto-responder, and CC/BCC recipients. Past the monthly limit, new submissions return 429 and are not stored until the counter resets on the 1st — see pricing.
Security & privacy
A form backend is a data processor, so the handling bar is high. Here is how splitforms treats submission data.
- Encryption. TLS on every request; submissions stored in Supabase Postgres with row-level security.
- No model training. Submission contents are never sold or used to train AI models.
- Your control. Delete or export any submission at any time, on any plan — no lock-in.
- GDPR. A DPA is available at /dpa; EU data residency is on the roadmap. Email hello@splitforms.com for a signed copy.
The access key is a public identifier, not a secret — lock forms to your domains with strict origin protection and rotate a key if it leaks (see Authentication). Field and payload caps (100 fields, 10 KB each) bound abuse.
MCP for AI agents
splitforms ships an MCP server so AI coding agents — Claude Code, Cursor, Windsurf — can list your forms, read submissions, generate template HTML, and wire up integrations without you copy-pasting credentials. Get your token from Dashboard → MCP and paste the install command into your editor.
Troubleshooting
I'm not receiving emails
- Check spam, junk, and promotions folders.
- Open the dashboard. If the submission is there, the API worked — the issue is email. Confirm
Settings → Email → Email tois a real mailbox. - On Microsoft 365 / Exchange, ask IT to safelist
@splitforms.com. - Check whether the entry is in the spam queue — quarantined submissions don't send a notification.
400 — "Missing access_key"
The access_keywasn't in the POST body. Ensure the input is inside the <form>, uses name="access_key" (not id), and isn't empty. For JSON, it must be a top-level key.
404 — "Invalid access_key"
The key matches no form. Common causes: whitespace pasted with the key, a rotated key still cached in your CDN, or posting your MCP/API token instead of the form access key. Copy it fresh from the dashboard.
415 — "Unsupported content type"
Send application/x-www-form-urlencoded, multipart/form-data, or application/json. This usually means a manually set Content-Typeheader that doesn't match the body — let the browser set it for FormData.
429 — "Too many submissions"
You hit the per-IP limit (6/min or 20/15min) or your monthly quota is spent. If real users hit the per-IP limit, a script is probably resubmitting on every keystroke rather than on submit.
CORS error in the console
The endpoint sends permissive CORS headers and handles OPTIONS, so a CORS error almost always means a wrong URL — a typo in /api/submit, or http:// instead of https://.
The page redirects to a JSON response
A native form with no redirect set fell through to JSON. Set a redirect in the dashboard, or submit with fetch and Accept: application/json and handle it yourself.
The honeypot blocks real users
Some password managers fill hidden fields. Keep the honeypot both display:none and tabindex="-1", use the literal name botcheck, and avoid names like email2 that autofill triggers on.
Still stuck? Email hello@splitforms.com with your account email, the last 4 characters of the access key, a timestamp, and the HTTP status and response body — we reply within a business day.
Frequently asked questions
What is the splitforms API endpoint?
Every form posts to one URL: https://splitforms.com/api/submit. It accepts application/x-www-form-urlencoded, multipart/form-data, and application/json. Include your access_key field plus whatever data fields you want captured. There is nothing else to learn to start receiving submissions.
Do I need a server or backend to use splitforms?
No. splitforms is the backend. Point a plain HTML form's action at /api/submit, add your access_key as a hidden input, and you have a working contact form — no Node server, no Lambda, no database, no SMTP. It also works from fetch in any framework if you prefer JSON.
How do I redirect to a thank-you page after submit?
Set the redirect URL per form in your dashboard (Form settings → Redirect). A submitted redirect field is intentionally ignored so splitforms can't be used as an open-redirect hop for phishing. For a fully custom success state, submit with fetch and the header Accept: application/json, then render your own UI from the JSON response.
How do I handle the response in JavaScript?
Send Accept: application/json (or Content-Type: application/json) and a successful submit returns HTTP 200 with { success: true, message: "Submission received" }. Errors return the same shape with success: false, a message, and a code, alongside an HTTP status of 400, 403, 404, 415, 429, or 500.
Does splitforms support file uploads?
Yes. Send the form as multipart/form-data to the same /api/submit URL — up to 5 files per submission and 5 MB per file. Uploads require a paid plan with the Storage integration connected; on the free plan the text fields still save but files are dropped and the JSON response says so.
Do submissions appear in my dashboard immediately?
Yes. The row is written synchronously before the API responds, so it appears in /dashboard on the next reload. Email and webhooks run in the background and usually arrive within seconds — but the dashboard is the source of truth: if the row is there, the submission was accepted.
How long are submissions retained?
Free submissions are retained for 30 days. Paid-plan (Pro and Business) submissions are retained for 365 days. The 3-Year plan retains submissions for 1,460 days. You can delete individual submissions or clear a form's history from the dashboard at any time, and export to CSV in one click.
Can I migrate from Formspree, Getform, Web3Forms, or Basin?
Usually a single-line change. Swap the form's action URL for https://splitforms.com/api/submit and replace the old endpoint ID with a splitforms access_key. Because every provider accepts standard form posts, the rest of your markup keeps working unchanged.
Is splitforms GDPR compliant?
Submissions are transmitted over TLS, stored in Supabase Postgres with row-level security, and never sold or used to train models. You can delete or export data at any time, and a DPA is available at /dpa. EU data residency is on the roadmap; email hello@splitforms.com for a signed DPA.
What happens if I exceed my monthly submission limit?
New submissions return HTTP 429 with a clear message and are not stored until the counter resets on the 1st of the month or you upgrade. Free covers 500/month; Pro is $5/month for 5,000, and the 3-Year plan is $59 for 15,000/month.
Next steps
Have a form working? Here's where to go from here.
- API reference — every status code, field, and error message in one place.
- Form backend guide — the concepts, trade-offs, and how splitforms compares.
- Live test form — confirm the endpoint and your key work end-to-end.
- Pricing — unlock webhooks, integrations, and file uploads.